mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
clap derive types replace the hand-written parser: typed values, usage errors (exit 2) before any work, help from the same definitions. Syntax changes: a repeated option is a usage error (was: last wins; the automation roots still repeat); a value starting with `-` needs `=`; a positional starting with `-` needs `--`; `--o` and the `version` subcommand are gone (`--version` stays); `<sub> --help` prints that subcommand's help; usage errors are clap's text; `--format` is checked before the file is opened.
This commit is contained in:
Generated
+54
@@ -746,6 +746,46 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
@@ -1737,6 +1777,7 @@ dependencies = [
|
||||
name = "figcraft-cli"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
@@ -1745,6 +1786,7 @@ dependencies = [
|
||||
"figcraft-svg",
|
||||
"figcraft-text",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2564,6 +2606,12 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -5006,6 +5054,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
|
||||
@@ -57,6 +57,8 @@ craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d39
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
# Command-line parsing for figcraft-cli (craft-contrib RFC-0012: clap derive, as photocraft does).
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -22,3 +22,5 @@ figcraft-svg = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
+510
-155
@@ -1,175 +1,530 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
|
||||
//! The command line, as clap types (craft-contrib RFC-0012). Values are parsed into their types
|
||||
//! here, so a bad one is a usage error naming its flag (exit 2) before any work starts, and the
|
||||
//! handlers never see it. A flag a subcommand doesn't take is a usage error, and so is one given
|
||||
//! twice, except the automation roots, which repeat. A value starting with `-` is written
|
||||
//! `--filter=-x`; after `--` every argument is a positional.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use crate::{CliError, CliResult};
|
||||
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
use figcraft_doc::{PrintSettings, VdCompat};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
/// FigCraft without a window
|
||||
///
|
||||
/// Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig
|
||||
/// (best effort; `info` lists what wasn't imported), .sketch (Sketch 48 or newer, best effort),
|
||||
/// .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer).
|
||||
#[derive(Parser)]
|
||||
#[command(name = "figcraft-cli", bin_name = "figcraft-cli", version, arg_required_else_help = true, after_help = "See docs/cli.md and docs/mcp.md.")]
|
||||
pub struct Cli {
|
||||
/// Lay text out with the fonts built into FigCraft only, for renders that match on every
|
||||
/// machine (or set FIGCRAFT_NO_SYSTEM_FONTS=1). Otherwise installed fonts are indexed the
|
||||
/// first time text is laid out, as in the app
|
||||
#[arg(long, global = true)]
|
||||
pub no_system_fonts: bool,
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
|
||||
let mut a = Args::default();
|
||||
// The first bad option, reported once the rest of the line is checked for `--help`.
|
||||
let mut bad: Option<CliError> = None;
|
||||
let mut it = argv.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if arg == "--" {
|
||||
a.positional.extend(it.cloned());
|
||||
break;
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
Info(InfoArgs),
|
||||
Export(ExportArgs),
|
||||
Convert(ConvertArgs),
|
||||
Render(RenderArgs),
|
||||
Run(RunArgs),
|
||||
Commands(CommandsArgs),
|
||||
Mcp(McpArgs),
|
||||
/// Publish plugins to a server's plugin directory
|
||||
Plugin {
|
||||
#[command(subcommand)]
|
||||
command: PluginCommand,
|
||||
},
|
||||
Perf(PerfArgs),
|
||||
}
|
||||
|
||||
/// The folders a file subcommand may touch. Without either flag it reads and writes as the user
|
||||
/// who started it; with one, reads are allowed only inside a read or write root and writes only
|
||||
/// inside a write root, so a read root alone denies writing altogether.
|
||||
#[derive(Args)]
|
||||
pub struct RootArgs {
|
||||
/// Confine file access: a folder that may be read (repeatable; it must exist)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_read_root: Vec<String>,
|
||||
/// Confine file access: a folder that may be read and written (repeatable; it must exist)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_write_root: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pages, node counts, top-level frames, style/variable/image counts
|
||||
#[derive(Args)]
|
||||
pub struct InfoArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// Print JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Export frames, a layer or the whole file
|
||||
///
|
||||
/// <OUT> is a folder: one file per top-level frame of the page. An <OUT> with an image extension,
|
||||
/// or --node, writes one file. `fig` (or an <OUT> ending in .fig) writes the whole file as .fig,
|
||||
/// experimental: other apps may not open every file. `penpot` (or an <OUT> ending in .penpot)
|
||||
/// writes the whole file as a Penpot 2.x archive. The format comes from --format, else <OUT>'s
|
||||
/// extension, else png.
|
||||
#[derive(Args)]
|
||||
pub struct ExportArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// The folder or file to write
|
||||
#[arg(short = 'o', long = "out", value_name = "OUT")]
|
||||
pub out: String,
|
||||
/// png, jpg, svg, webp, pdf, xml (Android Vector Drawable), fig or penpot
|
||||
#[arg(long, value_name = "FORMAT", value_parser = format)]
|
||||
pub format: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub scale: ScaleArg,
|
||||
/// The page to export (default the first)
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub page: Option<String>,
|
||||
/// The layer to export, by id (12 or 0:12) or name
|
||||
#[arg(long, value_name = "ID|NAME")]
|
||||
pub node: Option<String>,
|
||||
/// PDF: grow each page by this bleed, in points or a length like 3mm or 0.125in (up to 2in)
|
||||
#[arg(long, value_name = "LENGTH", value_parser = bleed, default_value = "0")]
|
||||
pub bleed: f64,
|
||||
/// PDF: draw crop marks outside the bleed
|
||||
#[arg(long)]
|
||||
pub crop_marks: bool,
|
||||
/// PDF: draw bleed marks outside the bleed
|
||||
#[arg(long)]
|
||||
pub bleed_marks: bool,
|
||||
/// PDF: write CMYK colour
|
||||
#[arg(long)]
|
||||
pub cmyk: bool,
|
||||
/// Android Vector Drawable: what the format can't draw is approximated (bestEffort), left out
|
||||
/// (exact) or dropped without notes (ignore)
|
||||
#[arg(long, value_name = "bestEffort|exact|ignore", value_parser = vd_compat, default_value = "bestEffort")]
|
||||
pub vd_compat: VdCompat,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
impl ExportArgs {
|
||||
/// The PDF print options.
|
||||
pub fn print(&self) -> PrintSettings {
|
||||
PrintSettings { bleed: self.bleed, crop_marks: self.crop_marks, bleed_marks: self.bleed_marks, cmyk: self.cmyk }
|
||||
}
|
||||
}
|
||||
|
||||
/// `--scale`, shared by `export` and `render`.
|
||||
#[derive(Args)]
|
||||
pub struct ScaleArg {
|
||||
/// Raster scale, in (0, 64]: 2 or 2x
|
||||
// allow_negative_numbers makes `--scale -3` an out-of-range value rather than an unexpected argument.
|
||||
#[arg(long, value_name = "N", value_parser = scale, default_value = "1", allow_negative_numbers = true)]
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// Convert by extension (.figcraft, .json, .svg, .png, .jpg, .webp, .pdf, .fig, .penpot)
|
||||
#[derive(Args)]
|
||||
pub struct ConvertArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "IN")]
|
||||
pub input: String,
|
||||
/// The file to write; its extension picks the format
|
||||
#[arg(value_name = "OUT")]
|
||||
pub output: String,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Render the whole page with its background as png, jpg or webp
|
||||
#[derive(Args)]
|
||||
pub struct RenderArgs {
|
||||
/// The document to read
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// The image to write
|
||||
#[arg(short = 'o', long = "out", value_name = "OUT.png")]
|
||||
pub out: String,
|
||||
#[command(flatten)]
|
||||
pub scale: ScaleArg,
|
||||
/// The page to render (default the first)
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub page: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Run a script of commands in a headless session, one JSON result line per step
|
||||
///
|
||||
/// The script is a JSON array (or JSON lines) of {"command": id, "params": {...}}, run as one
|
||||
/// all-or-nothing undo step.
|
||||
#[derive(Args)]
|
||||
pub struct RunArgs {
|
||||
/// The script to run
|
||||
#[arg(value_name = "SCRIPT.json|SCRIPT.jsonl")]
|
||||
pub script: String,
|
||||
/// Open this file first instead of a new document
|
||||
#[arg(long = "in", value_name = "FILE")]
|
||||
pub input: Option<String>,
|
||||
/// Save the result here, by extension
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub out: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// List every command with its label, shortcut, undoable flag (*) and parameters
|
||||
#[derive(Args)]
|
||||
pub struct CommandsArgs {
|
||||
/// Print JSON
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
/// Only the commands whose id, label or parameters contain this text
|
||||
#[arg(long, value_name = "TEXT")]
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Serve MCP on stdio: headless, or attached to a running app
|
||||
///
|
||||
/// The automation roots confine the headless session's file access. A server started with
|
||||
/// --attach is confined by the app it attaches to: start that app with `figcraft --control PORT`
|
||||
/// and the same roots.
|
||||
#[derive(Args)]
|
||||
pub struct McpArgs {
|
||||
/// Attach to the app started with `figcraft --control PORT`; a bare port means 127.0.0.1:PORT.
|
||||
/// Refuses the automation roots
|
||||
#[arg(long, value_name = "PORT", value_parser = attach, conflicts_with_all = ["automation_read_root", "automation_write_root"])]
|
||||
pub attach: Option<String>,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum PluginCommand {
|
||||
Publish(PublishArgs),
|
||||
}
|
||||
|
||||
/// Send a plugin folder to a figcraft-server's plugin directory as a new version
|
||||
///
|
||||
/// The folder holds figcraft-plugin.json, its module and an optional assets/ folder. The server's
|
||||
/// key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out
|
||||
/// of the shell history); it is never printed.
|
||||
#[derive(Args)]
|
||||
pub struct PublishArgs {
|
||||
/// The plugin folder
|
||||
#[arg(value_name = "FOLDER")]
|
||||
pub folder: String,
|
||||
/// The server, ws://host:port/ (optionally ?key=KEY)
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
/// Measure the hot paths on synthetic documents and print Markdown tables
|
||||
#[derive(Args)]
|
||||
pub struct PerfArgs {
|
||||
/// Document sizes in layers, each clamped to 10..=2,000,000
|
||||
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "1000,10000")]
|
||||
pub layers: Vec<usize>,
|
||||
/// Component instance counts, capped to 100,000; 0 skips the components table
|
||||
#[arg(long, value_name = "N[,N...]", value_parser = whole_number, value_delimiter = ',', action = ArgAction::Set, default_value = "2000")]
|
||||
pub instances: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ValueError {
|
||||
#[error("expected a number in (0, 64], like 2 or 2x")]
|
||||
Scale,
|
||||
#[error("expected points or a length like 3mm or 0.125in, up to 2in")]
|
||||
Bleed,
|
||||
#[error("expected bestEffort, exact or ignore")]
|
||||
VdCompat,
|
||||
#[error("expected png, jpg, svg, webp, pdf, xml, fig or penpot")]
|
||||
Format,
|
||||
#[error("expected whole numbers, like 1000,10_000")]
|
||||
WholeNumber,
|
||||
#[error("expected a port number or host:port")]
|
||||
Attach,
|
||||
}
|
||||
|
||||
fn scale(s: &str) -> Result<f64, ValueError> {
|
||||
s.trim_end_matches('x').parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0).ok_or(ValueError::Scale)
|
||||
}
|
||||
|
||||
fn bleed(s: &str) -> Result<f64, ValueError> {
|
||||
PrintSettings::parse_length(s).filter(|b| *b <= PrintSettings::MAX_BLEED).ok_or(ValueError::Bleed)
|
||||
}
|
||||
|
||||
fn vd_compat(s: &str) -> Result<VdCompat, ValueError> {
|
||||
VdCompat::from_key(s).ok_or(ValueError::VdCompat)
|
||||
}
|
||||
|
||||
/// `--format`, any case; kept as text for the exporter, which also reads it from the extension.
|
||||
fn format(s: &str) -> Result<String, ValueError> {
|
||||
let f = s.to_ascii_lowercase();
|
||||
match f.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" | "fig" | "penpot" => Ok(f),
|
||||
_ => Err(ValueError::Format),
|
||||
}
|
||||
}
|
||||
|
||||
fn whole_number(s: &str) -> Result<usize, ValueError> {
|
||||
s.trim().replace('_', "").parse().map_err(|_| ValueError::WholeNumber)
|
||||
}
|
||||
|
||||
/// `mcp --attach`: `host:port` as given, or a bare port on 127.0.0.1.
|
||||
fn attach(s: &str) -> Result<String, ValueError> {
|
||||
if s.contains(':') {
|
||||
return Ok(s.to_owned());
|
||||
}
|
||||
let port: u16 = s.parse().map_err(|_| ValueError::Attach)?;
|
||||
Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string())
|
||||
}
|
||||
|
||||
/// Parse the arguments, the program name first. clap reads them as `OsString`s, so a non-Unicode
|
||||
/// one is a usage error rather than a panic. A bare `--help` or `-h` before `--` is help even after
|
||||
/// a mistake on the same line (`mcp --attach nope --help`, `export -o --help`), which clap would
|
||||
/// report first.
|
||||
pub fn parse(args: Vec<OsString>) -> Result<Cli, clap::Error> {
|
||||
let mut cmd = Cli::command();
|
||||
match cmd.try_get_matches_from_mut(&args) {
|
||||
Ok(matches) => Cli::from_arg_matches(&matches),
|
||||
Err(e) => match args.iter().skip(1).take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") {
|
||||
Some(flag) => {
|
||||
let help: Vec<OsString> = subcommand_path(&cmd, &args).into_iter().chain([flag.clone()]).collect();
|
||||
Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e))
|
||||
}
|
||||
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())) {
|
||||
n
|
||||
} else {
|
||||
a.positional.push(arg.clone());
|
||||
continue;
|
||||
};
|
||||
let (name, inline) = match name.split_once('=') {
|
||||
Some((n, v)) => (n, Some(v.to_string())),
|
||||
None => (name, None),
|
||||
};
|
||||
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
|
||||
if inline.is_none() && matches!(name, "help" | "h") {
|
||||
return Err(CliError::Help);
|
||||
}
|
||||
if valued.contains(&name) {
|
||||
match inline.or_else(|| it.next().cloned()) {
|
||||
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
|
||||
None => {
|
||||
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
|
||||
}
|
||||
}
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
}
|
||||
}
|
||||
match bad {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(a),
|
||||
}
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
self.flags.iter().any(|f| f == k)
|
||||
}
|
||||
|
||||
pub fn one_positional(&self, usage: &str) -> CliResult<String> {
|
||||
match self.positional.as_slice() {
|
||||
[one] => Ok(one.clone()),
|
||||
_ => Err(CliError::Usage(format!("expected: {usage}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
|
||||
}
|
||||
|
||||
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
|
||||
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
|
||||
match self.value("vd-compat") {
|
||||
None => Ok(figcraft_doc::VdCompat::default()),
|
||||
Some(s) => figcraft_doc::VdCompat::from_key(&s)
|
||||
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite() && *v > 0.0 && *v <= 64.0)
|
||||
.ok_or_else(|| CliError::Usage(format!("--scale must be a number in (0, 64], got `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
.map(|v| v.trim().replace('_', "").parse::<usize>().map_err(|_| CliError::Usage(format!("--{k} must be whole numbers, got `{s}`"))))
|
||||
.collect(),
|
||||
}
|
||||
/// The program name and the subcommands `args` names (`figcraft-cli plugin publish`): at each
|
||||
/// level, the first argument that isn't a flag, while it names a subcommand.
|
||||
fn subcommand_path(cmd: &clap::Command, args: &[OsString]) -> Vec<OsString> {
|
||||
let mut path: Vec<OsString> = args.first().cloned().into_iter().collect();
|
||||
let mut at = cmd;
|
||||
let mut rest = args.iter().skip(1).filter(|a| !a.to_string_lossy().starts_with('-'));
|
||||
while let Some(sub) = rest.next().and_then(|a| a.to_str()).and_then(|name| at.find_subcommand(name)) {
|
||||
path.push(sub.get_name().into());
|
||||
at = sub;
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
use clap::error::ErrorKind;
|
||||
|
||||
fn v(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|x| x.to_string()).collect()
|
||||
use super::{Cli, Command, PluginCommand, parse};
|
||||
|
||||
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
|
||||
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
|
||||
}
|
||||
|
||||
fn ok(args: &[&str]) -> Command {
|
||||
match try_parse(args) {
|
||||
Ok(cli) => cli.command,
|
||||
Err(e) => panic!("{args:?}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(args: &[&str]) -> ErrorKind {
|
||||
match try_parse(args) {
|
||||
Ok(_) => panic!("{args:?} parsed"),
|
||||
Err(e) => e.kind(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_definition_is_consistent() {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_and_the_roots() {
|
||||
let Command::Info(a) = ok(&["info", "a.figcraft", "--json", "--automation-read-root", "in", "--automation-read-root=more"]) else {
|
||||
panic!("info")
|
||||
};
|
||||
assert_eq!(a.file, "a.figcraft");
|
||||
assert!(a.json);
|
||||
assert_eq!(a.roots.automation_read_root, ["in", "more"]);
|
||||
assert!(a.roots.automation_write_root.is_empty());
|
||||
let Command::Info(a) = ok(&["info", "--json", "--", "--drawing.figcraft"]) else { panic!("info") };
|
||||
assert_eq!(a.file, "--drawing.figcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_takes_every_documented_option() {
|
||||
let Command::Export(a) = ok(&[
|
||||
"export",
|
||||
"app.figcraft",
|
||||
"-o",
|
||||
"poster.pdf",
|
||||
"--format=PDF",
|
||||
"--scale",
|
||||
"2x",
|
||||
"--page",
|
||||
"Screens",
|
||||
"--node",
|
||||
"Poster",
|
||||
"--bleed",
|
||||
"3mm",
|
||||
"--crop-marks",
|
||||
"--bleed-marks",
|
||||
"--cmyk",
|
||||
"--vd-compat",
|
||||
"exact",
|
||||
]) else {
|
||||
panic!("export")
|
||||
};
|
||||
assert_eq!((a.file.as_str(), a.out.as_str(), a.format.as_deref()), ("app.figcraft", "poster.pdf", Some("pdf")));
|
||||
assert_eq!((a.scale.scale, a.page.as_deref(), a.node.as_deref()), (2.0, Some("Screens"), Some("Poster")));
|
||||
let print = a.print();
|
||||
assert!((print.bleed - 3.0 * 72.0 / 25.4).abs() < 1e-9, "{}", print.bleed);
|
||||
assert!(print.crop_marks && print.bleed_marks && print.cmyk);
|
||||
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::Exact);
|
||||
// The defaults, and `--out` for `-o`.
|
||||
let Command::Export(a) = ok(&["export", "app.figcraft", "--out=exports/"]) else { panic!("export") };
|
||||
assert_eq!((a.out.as_str(), a.format.as_deref(), a.scale.scale, a.print().bleed), ("exports/", None, 1.0, 0.0));
|
||||
assert_eq!(a.vd_compat, figcraft_doc::VdCompat::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_render_run_commands_perf() {
|
||||
let Command::Convert(a) = ok(&["convert", "in.svg", "out.figcraft"]) else { panic!("convert") };
|
||||
assert_eq!((a.input.as_str(), a.output.as_str()), ("in.svg", "out.figcraft"));
|
||||
let Command::Render(a) = ok(&["render", "doc.figcraft", "--out", "doc.png", "--scale=0.5", "--page", "P"]) else { panic!("render") };
|
||||
assert_eq!((a.out.as_str(), a.scale.scale, a.page.as_deref()), ("doc.png", 0.5, Some("P")));
|
||||
let Command::Run(a) = ok(&["run", "s.jsonl", "--in", "a.figcraft", "--out", "b.figcraft", "--automation-write-root", "."]) else {
|
||||
panic!("run")
|
||||
};
|
||||
assert_eq!((a.script.as_str(), a.input.as_deref(), a.out.as_deref()), ("s.jsonl", Some("a.figcraft"), Some("b.figcraft")));
|
||||
assert_eq!(a.roots.automation_write_root, ["."]);
|
||||
let Command::Commands(a) = ok(&["commands", "--json", "--filter=--help"]) else { panic!("commands") };
|
||||
assert!(a.json);
|
||||
assert_eq!(a.filter.as_deref(), Some("--help"));
|
||||
let Command::Perf(a) = ok(&["perf"]) else { panic!("perf") };
|
||||
assert_eq!((a.layers, a.instances), (vec![1000, 10_000], vec![2000]));
|
||||
let Command::Perf(a) = ok(&["perf", "--layers", "1000,10_000, 50000", "--instances=0"]) else { panic!("perf") };
|
||||
assert_eq!((a.layers, a.instances), (vec![1000, 10_000, 50_000], vec![0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_and_plugin_publish() {
|
||||
let Command::Mcp(a) = ok(&["mcp"]) else { panic!("mcp") };
|
||||
assert_eq!(a.attach, None);
|
||||
let Command::Mcp(a) = ok(&["mcp", "--attach", "7979"]) else { panic!("mcp") };
|
||||
assert_eq!(a.attach.as_deref(), Some("127.0.0.1:7979"));
|
||||
let Command::Mcp(a) = ok(&["mcp", "--attach=localhost:7979"]) else { panic!("mcp") };
|
||||
assert_eq!(a.attach.as_deref(), Some("localhost:7979"));
|
||||
let Command::Mcp(a) = ok(&["mcp", "--automation-read-root", "in", "--automation-write-root", "out"]) else { panic!("mcp") };
|
||||
assert_eq!((a.roots.automation_read_root, a.roots.automation_write_root), (vec!["in".to_owned()], vec!["out".to_owned()]));
|
||||
let Command::Plugin { command: PluginCommand::Publish(a) } = ok(&["plugin", "publish", "dist/counter", "--server", "ws://h:47900/"]) else {
|
||||
panic!("plugin")
|
||||
};
|
||||
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_flag_goes_before_or_after_the_subcommand() {
|
||||
for args in [&["--no-system-fonts", "commands"][..], &["commands", "--no-system-fonts"]] {
|
||||
assert!(try_parse(args).is_ok_and(|cli| cli.no_system_fonts), "{args:?}");
|
||||
}
|
||||
assert!(try_parse(&["commands"]).is_ok_and(|cli| !cli.no_system_fonts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_are_usage_errors() {
|
||||
use ErrorKind::{
|
||||
ArgumentConflict, DisplayHelpOnMissingArgumentOrSubcommand, InvalidSubcommand, InvalidValue, MissingRequiredArgument, TooManyValues,
|
||||
UnknownArgument, ValueValidation,
|
||||
};
|
||||
let cases: &[(&[&str], ErrorKind)] = &[
|
||||
(&[], DisplayHelpOnMissingArgumentOrSubcommand),
|
||||
(&["bogus"], InvalidSubcommand),
|
||||
(&["version"], InvalidSubcommand),
|
||||
(&["export", "x.figcraft"], MissingRequiredArgument),
|
||||
(&["info", "--nope", "a"], UnknownArgument),
|
||||
(&["info", "a", "b"], UnknownArgument),
|
||||
(&["info", "--json=1", "a"], TooManyValues),
|
||||
(&["render", "a", "-o"], InvalidValue),
|
||||
(&["export", "a", "-o", "b", "--scale", "0"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--scale", "-3"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--scale", "65"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--bleed", "lots"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--bleed", "3in"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--vd-compat", "sloppy"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--format", "bmp"], ValueValidation),
|
||||
(&["export", "a", "-o", "b", "--page", "A", "--page", "B"], ArgumentConflict),
|
||||
(&["perf", "--shapes", "lots"], UnknownArgument),
|
||||
(&["perf", "--layers", "10,x"], ValueValidation),
|
||||
(&["perf", "--layers", "10", "--layers", "20"], ArgumentConflict),
|
||||
(&["mcp", "--attach", "notaport"], ValueValidation),
|
||||
(&["mcp", "--attach", "7979", "--automation-read-root", "."], ArgumentConflict),
|
||||
(&["mcp", "--automation-write-root"], InvalidValue),
|
||||
(&["mcp", "stray"], UnknownArgument),
|
||||
(&["convert", "a"], MissingRequiredArgument),
|
||||
(&["plugin", "publish", "dist"], MissingRequiredArgument),
|
||||
(&["commands", "--filter", "--json"], InvalidValue),
|
||||
];
|
||||
for (args, want) in cases {
|
||||
assert_eq!(kind(args), *want, "{args:?}");
|
||||
assert_eq!(try_parse(args).err().map(|e| e.exit_code()), Some(2), "{args:?}");
|
||||
}
|
||||
assert_eq!(try_parse(&["plugin"]).err().map(|e| e.exit_code()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_version_exit_0() {
|
||||
assert_eq!(kind(&["--version"]), ErrorKind::DisplayVersion);
|
||||
assert_eq!(kind(&["-V"]), ErrorKind::DisplayVersion);
|
||||
for args in [
|
||||
&["--help"][..],
|
||||
&["-h"],
|
||||
&["help"],
|
||||
&["help", "export"],
|
||||
&["export", "x.figcraft", "-o", "out", "--help"],
|
||||
&["export", "-o", "--help"],
|
||||
&["mcp", "--attach", "notaport", "--help"],
|
||||
&["mcp", "--bogus", "-h"],
|
||||
&["--no-system-fonts", "plugin", "publish", "--bogus", "--help"],
|
||||
] {
|
||||
let e = try_parse(args).err();
|
||||
assert_eq!(e.as_ref().map(clap::Error::kind), Some(ErrorKind::DisplayHelp), "{args:?}");
|
||||
assert_eq!(e.map(|e| e.exit_code()), Some(0), "{args:?}");
|
||||
}
|
||||
// The help is the subcommand's, even after a mistake.
|
||||
let help = try_parse(&["plugin", "publish", "--bogus", "--help"]).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(help.contains("Usage: figcraft-cli plugin publish"), "{help}");
|
||||
// After `--`, `-h` is a file name.
|
||||
let Command::Info(a) = ok(&["info", "--", "-h"]) else { panic!("info") };
|
||||
assert_eq!(a.file, "-h");
|
||||
}
|
||||
|
||||
/// Every subcommand's help renders and documents each of its options.
|
||||
#[test]
|
||||
fn parses_options_and_flags() {
|
||||
let a = Args::parse(&v(&["in.figcraft", "-o", "out", "--scale=2", "--json"]), &["o", "scale"], &["json"]).unwrap();
|
||||
assert_eq!(a.positional, ["in.figcraft"]);
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert!(matches!(Args::parse(&v(&["--nope"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
assert!(matches!(Args::parse(&v(&["-o"]), &["o"], &[]), Err(CliError::Usage(_))));
|
||||
assert!(matches!(Args::parse(&v(&["--json=1"]), &[], &["json"]), Err(CliError::Usage(_))));
|
||||
let a = Args::parse(&v(&["--scale", "abc"]), &["scale"], &[]).unwrap();
|
||||
assert!(a.scale().is_err());
|
||||
let a = Args::parse(&v(&["--scale", "-3"]), &["scale"], &[]).unwrap();
|
||||
assert!(a.scale().is_err());
|
||||
// Negative numbers are positionals, not options.
|
||||
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
|
||||
assert_eq!(a.positional, ["-5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_asked_for_anywhere_but_as_a_value() {
|
||||
// Also after a bad option: help wins over the usage error (#1161 review).
|
||||
for argv in
|
||||
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
|
||||
{
|
||||
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
|
||||
fn every_subcommand_has_help() {
|
||||
let mut cli = Cli::command();
|
||||
cli.build();
|
||||
let mut todo = vec![cli];
|
||||
while let Some(mut cmd) = todo.pop() {
|
||||
let help = cmd.render_long_help().to_string();
|
||||
assert!(help.contains("Usage: figcraft-cli"), "{}: {help}", cmd.get_name());
|
||||
for arg in cmd.get_arguments().filter(|a| !matches!(a.get_long(), Some("help" | "version"))) {
|
||||
assert!(arg.get_help().is_some(), "{} {}", cmd.get_name(), arg.get_id());
|
||||
}
|
||||
todo.extend(cmd.get_subcommands().filter(|s| s.get_name() != "help").cloned());
|
||||
}
|
||||
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
|
||||
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
|
||||
// `--help=x` is no request for help, just an unknown option.
|
||||
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
}
|
||||
}
|
||||
|
||||
+42
-168
@@ -10,77 +10,38 @@ mod perf;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use args::Args;
|
||||
use args::{Cli, Command, PluginCommand, RootArgs};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
||||
|
||||
USAGE:
|
||||
figcraft-cli info <file> [--json]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
|
||||
|
||||
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
|
||||
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
|
||||
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
|
||||
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
|
||||
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
|
||||
confined by the app it attaches to, not by flags passed to the CLI: start that app with
|
||||
`figcraft --control PORT` and the same roots (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
fn roots_of(r: &RootArgs) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
if r.automation_read_root.is_empty() && r.automation_write_root.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(&r.automation_read_root, &r.automation_write_root).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
/// Runs `f` confined to the roots `r` carries.
|
||||
fn confined<R>(r: &RootArgs, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(r)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Help) => {
|
||||
println!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
// `args_os`: a non-Unicode argument is clap's usage error, not a panic.
|
||||
let cli = match args::parse(std::env::args_os().collect()) {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
// Help and `--version` on stdout with 0, usage errors on stderr with 2.
|
||||
let _ = e.print();
|
||||
return ExitCode::from(u8::try_from(e.exit_code()).unwrap_or(2));
|
||||
}
|
||||
};
|
||||
match run(cli) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Usage(msg)) => {
|
||||
eprintln!("error: {msg}\n\n{USAGE}");
|
||||
eprintln!("error: {msg}\n\nFor more information, try '--help'.");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
Err(CliError::Failed(msg)) => {
|
||||
@@ -92,9 +53,7 @@ fn main() -> ExitCode {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CliError {
|
||||
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
|
||||
Help,
|
||||
/// Bad arguments: usage is printed, exit code 2.
|
||||
/// Bad arguments found after parsing (an output extension no format writes): exit code 2.
|
||||
Usage(String),
|
||||
/// The operation failed: exit code 1.
|
||||
Failed(String),
|
||||
@@ -108,10 +67,8 @@ impl From<String> for CliError {
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
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") {
|
||||
fn run(cli: Cli) -> CliResult<ExitCode> {
|
||||
if !cli.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();
|
||||
@@ -119,106 +76,28 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Arg
|
||||
// Nothing scans them then, not even a `.sketch` import matching its fonts.
|
||||
figcraft_text::opt_out_of_system_fonts();
|
||||
}
|
||||
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}");
|
||||
match cli.command {
|
||||
Command::Info(a) => confined(&a.roots, || files::info(&a.file, a.json)),
|
||||
Command::Export(a) => {
|
||||
let target =
|
||||
files::Target { print: a.print(), vd_compat: a.vd_compat, scale: a.scale.scale, format: a.format, page: a.page, node: a.node };
|
||||
confined(&a.roots, || files::export(&a.file, &a.out, &target))
|
||||
}
|
||||
Command::Convert(a) => confined(&a.roots, || files::convert(&a.input, &a.output)),
|
||||
Command::Render(a) => confined(&a.roots, || files::render(&a.file, &a.out, a.scale.scale, a.page.as_deref())),
|
||||
Command::Run(a) => confined(&a.roots, || files::run_script(&a.script, a.input.as_deref(), a.out.as_deref())),
|
||||
Command::Commands(a) => {
|
||||
commands(a.json, a.filter.as_deref());
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"--version" | "-V" | "version" => {
|
||||
println!("figcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(ExitCode::SUCCESS)
|
||||
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
|
||||
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
|
||||
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
|
||||
Command::Perf(a) => {
|
||||
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
||||
perf::run(&sizes, &instances, !cli.no_system_fonts)
|
||||
}
|
||||
"info" => {
|
||||
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", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
|
||||
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 = files::Target {
|
||||
format: a.value("format"),
|
||||
scale: a.scale()?,
|
||||
page: a.value("page"),
|
||||
node: a.value("node"),
|
||||
print: a.print()?,
|
||||
vd_compat: a.vd_compat()?,
|
||||
};
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
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 = 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 = parse_args(&rest, &["filter"], &["json"])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("commands takes no positional arguments".into()));
|
||||
}
|
||||
commands(a.flag("json"), a.value("filter").as_deref());
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
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()))?;
|
||||
publish_plugin(folder, &server)
|
||||
}
|
||||
_ => Err(CliError::Usage("expected: plugin publish <folder> --server ws://host:port/".into())),
|
||||
}
|
||||
}
|
||||
"perf" => {
|
||||
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, !a.flag("no-system-fonts"))
|
||||
}
|
||||
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,13 +149,8 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
let addr = if p.contains(':') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("127.0.0.1:{}", p.parse::<u16>().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?)
|
||||
};
|
||||
let b = figcraft_mcp::Attached::connect(&addr)
|
||||
Some(addr) => {
|
||||
let b = figcraft_mcp::Attached::connect(addr)
|
||||
.map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?;
|
||||
Box::new(b)
|
||||
}
|
||||
|
||||
@@ -535,17 +535,6 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn layer_sizes_parse_as_a_list() {
|
||||
use crate::args::Args;
|
||||
let argv = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
||||
let a = Args::parse(&argv(&["--layers", "1000,10_000, 50000"]), &["layers"], &[]).unwrap();
|
||||
assert_eq!(a.usizes("layers", &[1]).unwrap(), vec![1000, 10_000, 50_000]);
|
||||
assert_eq!(Args::parse(&[], &["layers"], &[]).unwrap().usizes("layers", &[7]).unwrap(), vec![7]);
|
||||
let bad = Args::parse(&argv(&["--layers", "10,x"]), &["layers"], &[]).unwrap();
|
||||
assert!(bad.usizes("layers", &[1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_components_at_a_small_scale() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
|
||||
@@ -65,8 +65,9 @@ fn option_terminator_allows_dash_prefixed_filenames() {
|
||||
|
||||
#[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();
|
||||
// A value starting with `-` is written with `=` (RFC-0012).
|
||||
for filter in ["--filter=--", "--filter=--no-system-fonts"] {
|
||||
let output = bin().args(["commands", 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());
|
||||
}
|
||||
@@ -98,15 +99,15 @@ fn bad_arguments_exit_2_without_panicking() {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("USAGE"), "{args:?}");
|
||||
assert!(err.contains("Usage: figcraft-cli") || err.contains("try '--help'"), "{args:?}: {err}");
|
||||
assert!(!err.contains("panicked"));
|
||||
}
|
||||
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
|
||||
/// (#1155), whatever else is on the line; nothing runs.
|
||||
/// `--help` / `-h` after a subcommand prints that subcommand's help on stdout and exits 0, as it
|
||||
/// does first (#1155), whatever else is on the line; nothing runs.
|
||||
#[test]
|
||||
fn help_after_a_subcommand_prints_the_usage() {
|
||||
for args in [
|
||||
@@ -130,13 +131,13 @@ fn help_after_a_subcommand_prints_the_usage() {
|
||||
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
|
||||
assert!(text.contains("Usage: figcraft-cli"), "{args:?}: {text}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
// A value that happens to be `-h` is still a value, not a request for help.
|
||||
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
|
||||
// A value that happens to be `--help` is still a value, not a request for help.
|
||||
let out = bin().args(["commands", "--filter=--help"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("Usage:"), "--filter took `--help` as its text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+25
-6
@@ -4,13 +4,32 @@ FigCraft without a window: inspect, export, convert and render documents, run co
|
||||
commands, serve MCP and measure performance. Build it with
|
||||
`cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`).
|
||||
|
||||
`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does
|
||||
`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit
|
||||
with code 2; failures exit with 1. The CLI never panics on input.
|
||||
The CLI follows [RFC-0012](https://github.com/storytold/craft-contrib/blob/main/rfcs/0012-cli-conventions.md),
|
||||
the command-line conventions the Craft apps share, in how it parses its arguments (with clap). The
|
||||
rules it doesn't follow yet are listed below. `figcraft-cli --help` is the source of truth for
|
||||
the flags; this page explains what they do.
|
||||
|
||||
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`.
|
||||
`figcraft-cli --help` (or `-h`, `help`, `help <subcommand>`) prints help on stdout and exits with
|
||||
0. So does `--help` or `-h` after a subcommand (`figcraft-cli mcp --help`), even after a mistake on
|
||||
the same line. `--version` (or `-V`) prints the version. Bad arguments are usage errors, caught
|
||||
before any work starts: an option the subcommand doesn't take, an option given twice (only the
|
||||
automation roots repeat), a missing or bad value (`--scale 0`, `--format bmp`, `--attach nope`)
|
||||
and `mcp --attach` with automation roots. They print `error: …` on stderr and exit with code 2.
|
||||
Failures exit with 1. The CLI never panics on input.
|
||||
|
||||
A value that starts with `-` is written with `=`, for example `--filter=--json`, so an option never
|
||||
takes the next option as its value. 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`.
|
||||
|
||||
Not yet as RFC-0012 asks:
|
||||
- Arguments that name files are read as text, so a file name that isn't valid Unicode is a usage
|
||||
error (exit 2) rather than reaching the command.
|
||||
- Failures are messages rather than error enums, and `main` isn't yet a `run(args, out, err)`
|
||||
function the tests call in-process.
|
||||
- `FIGCRAFT_NO_SYSTEM_FONTS` and `FIGCRAFT_SERVER_KEY` are read where they're used, not declared
|
||||
on a flag, so `--help` names them only in the flag descriptions.
|
||||
- There are no generated shell completions or man pages, and no ast-grep checks in CI yet.
|
||||
|
||||
## Fonts
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
|
||||
Reference in New Issue
Block a user