diff --git a/Cargo.lock b/Cargo.lock index 45a9975..b594d5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,9 @@ name = "anyhow" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +dependencies = [ + "backtrace", +] [[package]] name = "approx" @@ -757,6 +760,28 @@ dependencies = [ "piper", ] +[[package]] +name = "bpaf" +version = "0.9.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d4e5ca9929037866947af4b8b7418124f2ec7c411a8b9ee24e46ad2b8470497" +dependencies = [ + "bpaf_derive", + "owo-colors", + "supports-color", +] + +[[package]] +name = "bpaf_derive" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf95d9c7e6aba67f8fc07761091e93254677f4db9e27197adecebc7039a58722" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -2330,6 +2355,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.0" @@ -3077,6 +3108,12 @@ dependencies = [ "ttf-parser 0.21.1", ] +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + [[package]] name = "parking" version = "2.2.0" @@ -3896,6 +3933,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8775305acf21c96926c900ad056abeef436701108518cf890020387236ac5a77" +dependencies = [ + "is_ci", +] + [[package]] name = "svg" version = "0.17.0" @@ -4409,6 +4455,8 @@ dependencies = [ name = "vsvg-cli" version = "0.6.0-alpha.0" dependencies = [ + "anyhow", + "bpaf", "clap", "dhat", "eframe", @@ -4417,6 +4465,7 @@ dependencies = [ "rand", "rand_chacha", "serde", + "thiserror", "tracing-subscriber", "vsvg", "vsvg-viewer", diff --git a/Cargo.toml b/Cargo.toml index 9a7807d..b4dde2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ approx = "0.5.1" arrayvec = "0.7.2" base64 = "0.22.1" bitvec = "1.0.1" +bpaf = "0.9" bumpalo = "3.14.0" # avoid yanked 3.12.1, pulled by wasm-bindgen bytemuck = "1.13.1" camino = "1.1.0" diff --git a/crates/vsvg-cli/Cargo.toml b/crates/vsvg-cli/Cargo.toml index 45ce9c9..8bc7e25 100644 --- a/crates/vsvg-cli/Cargo.toml +++ b/crates/vsvg-cli/Cargo.toml @@ -26,14 +26,17 @@ path = "src/main.rs" vsvg = { workspace = true, features = ["egui"] } vsvg-viewer.workspace = true +anyhow = { workspace = true, features = ["backtrace"] } +bpaf = { workspace = true, features = ["derive", "autocomplete", "dull-color"] } clap = { workspace = true, features = ["cargo"] } -dhat = { workspace = true, optional = true } # for heap profiling +dhat = { workspace = true, optional = true } # for heap profiling eframe.workspace = true egui.workspace = true kurbo.workspace = true rand.workspace = true rand_chacha.workspace = true serde.workspace = true +thiserror = { workspace = true } tracing-subscriber.workspace = true diff --git a/crates/vsvg-cli/src/cli.rs b/crates/vsvg-cli/src/cli.rs index 9e60038..b7ae982 100644 --- a/crates/vsvg-cli/src/cli.rs +++ b/crates/vsvg-cli/src/cli.rs @@ -1,234 +1,44 @@ -use clap::{arg, command, value_parser, Arg, ArgAction, ArgGroup, ArgMatches, Command, Id}; -use std::collections::{BTreeMap, HashMap}; -use std::error::Error; +use crate::commands::{context, draw, io, layers, ops, transforms, DynCommand, State}; +use bpaf::{construct, short, OptionParser, Parser}; -use std::fmt::{Debug, Display, Formatter}; - -use crate::draw_state::{DrawState, LayerDrawer}; -use std::path::PathBuf; -use vsvg::{Document, DocumentTrait, LayerID}; - -/// A trait for types that can be used as command line arguments. -trait CommandArg: Clone + Into + Send + Sync + Debug + 'static {} -impl + Send + Sync + Debug + 'static> CommandArg for T {} - -#[derive(Debug)] -pub enum CliError { - NotAVector, -} - -impl Display for CliError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - CliError::NotAVector => write!(f, "Not a vector"), - } - } -} - -impl Error for CliError {} - -#[derive(Debug)] -pub(crate) struct State { - pub document: Document, - pub draw_state: DrawState, - pub draw_layer: LayerID, -} - -impl State { - pub(crate) fn draw(&mut self) -> LayerDrawer { - LayerDrawer { - state: &self.draw_state, - layer: self.document.get_mut(self.draw_layer), - } - } +struct Options { + verbose: bool, + commands: Vec, } -impl Default for State { - fn default() -> Self { - Self { - draw_layer: 1, - draw_state: DrawState::default(), - document: Document::default(), - } - } -} - -type CommandAction = dyn Fn(&CommandValue, &mut State) -> Result<(), Box>; - -pub(crate) struct CommandDesc<'a> { - pub(crate) id: Id, - pub(crate) arg: Arg, - pub(crate) multiple_args: bool, - pub(crate) action: &'a CommandAction, -} - -impl<'a> CommandDesc<'a> { - pub(crate) fn new(arg: Arg, action: &'a CommandAction) -> Self { - let multiple_args = arg.get_num_args().unwrap_or_default().max_values() > 1; - Self { - id: arg.get_id().clone(), - arg: arg.group("commands").action(ArgAction::Append), - multiple_args, - action, - } - } -} - -pub(crate) fn cli(command_descs: &HashMap) -> Command { - let mut cli = command!() - .args([ - arg!( "Path to the SVG file (or '-' for stdin)") - .value_parser(value_parser!(PathBuf)), - Arg::new("single-layer") - .long("single") - .help("Single layer mode") - .num_args(0), - Arg::new("no-show") - .long("no-show") - .help("Don't show the GUI") - .num_args(0), - arg!(-v --verbose "Enable debug output"), - ]) - .group(ArgGroup::new("commands").multiple(true)) - .next_help_heading("COMMANDS"); - - for command in command_descs.values() { - cli = cli.arg(command.arg.clone()); - } - - cli +/// Parser for all possible commands. +fn command() -> impl Parser { + let context = context::parser(); + let draw = draw::parser(); + let io = io::parser(); + let layers = layers::parser(); + let ops = ops::parser(); + let transform = transforms::parser(); + construct!([context, io, layers, ops, transform, draw]) } -#[derive(Clone, PartialEq, Debug)] -pub enum CommandValue { - Bool(bool), - String(String), - Float(f64), - Vector(Vec), - LayerID(LayerID), +/// Parser for the top-level options. +fn options() -> OptionParser { + let verbose = short('v') + .long("verbose") + .help("Enable verbose output") + .switch(); + let commands = command().many(); + construct!(Options { verbose, commands }).to_options() } -impl CommandValue { - pub(crate) fn from_matches( - matches: &ArgMatches, - command_descs: &HashMap, - ) -> Vec<(Id, Self)> { - let mut values = BTreeMap::new(); - for id in matches.ids() { - if matches.try_get_many::(id.as_str()).is_ok() { - // ignore groups - continue; - } - let value_source = matches - .value_source(id.as_str()) - .expect("id came from matches"); - if value_source != clap::parser::ValueSource::CommandLine { - // Any other source just gets tacked on at the end (like default values) - continue; - } - - let desc = command_descs.get(id).expect("id came from matches"); - - if Self::extract::(matches, id, desc, &mut values) { - continue; - } - if Self::extract::(matches, id, desc, &mut values) { - continue; - } - if Self::extract::(matches, id, desc, &mut values) { - continue; - } - if Self::extract::(matches, id, desc, &mut values) { - continue; - } - unimplemented!("unknown type for {}: {:?}", id, matches); - } - values.into_values().collect::>() - } - - fn extract( - matches: &ArgMatches, - id: &Id, - command_desc: &CommandDesc, - output: &mut BTreeMap, - ) -> bool { - #[allow(clippy::match_same_arms)] - match matches.try_get_many::(id.as_str()) { - Ok(Some(_)) => { - let occurrences: Vec> = matches - .get_occurrences(id.as_str()) - .expect("id came from matches") - .map(|occ| occ.cloned().collect()) - .collect(); - - let indices: Vec = matches - .indices_of(id.as_str()) - .expect("id came from matches") - .collect(); +/// Run the CLI. +pub fn cli() -> anyhow::Result<()> { + let options = options().run(); - let mut indices_idx = 0_usize; - for value in &occurrences { - let index = indices[indices_idx]; + let mut state = State::default(); - if command_desc.multiple_args { - output.insert(index, (id.clone(), value.clone().into())); - } else { - output.insert(index, (id.clone(), value[0].clone().into())); - } - - indices_idx += value.len(); - } - - true - } - Ok(None) => { - unreachable!("`ids` only reports what is present") - } - Err(clap::parser::MatchesError::UnknownArgument { .. }) => { - unreachable!("id came from matches") - } - Err(clap::parser::MatchesError::Downcast { .. }) => false, - - Err(_) => { - unreachable!("id came from matches") - } + for command in options.commands { + if options.verbose { + println!("Executing stage: {:?}", command); } + command.execute(&mut state)?; } - pub(crate) fn try_vector(&self) -> Result<&[CommandValue], CliError> { - match self { - Self::Vector(v) => Ok(&v[..]), - _ => Err(CliError::NotAVector), - } - } -} - -impl From for CommandValue { - fn from(other: String) -> Self { - Self::String(other) - } -} - -impl From for CommandValue { - fn from(other: bool) -> Self { - Self::Bool(other) - } -} - -impl From for CommandValue { - fn from(other: f64) -> Self { - Self::Float(other) - } -} - -impl From for CommandValue { - fn from(other: LayerID) -> Self { - Self::LayerID(other) - } -} - -impl From> for CommandValue { - fn from(other: Vec) -> Self { - Self::Vector(other.into_iter().map(std::convert::Into::into).collect()) - } + Ok(()) } diff --git a/crates/vsvg-cli/src/commands.rs b/crates/vsvg-cli/src/commands.rs deleted file mode 100644 index f322505..0000000 --- a/crates/vsvg-cli/src/commands.rs +++ /dev/null @@ -1,287 +0,0 @@ -use crate::cli::{CommandDesc, CommandValue}; -use clap::{arg, value_parser, Arg, Id}; -use std::collections::HashMap; -use vsvg::{DocumentTrait, Draw, LayerTrait, PathTrait, Transforms}; - -// https://stackoverflow.com/a/38361018/229511 -macro_rules! count_items { - ($name:ident) => { 1 }; - ($first:ident, $($rest:ident),*) => { - 1 + count_items!($($rest),*) - } -} - -// copied from min_max crate -macro_rules! min { - ($x:expr) => ( $x ); - ($x:expr, $($xs:expr),+) => { - std::cmp::min($x, min!( $($xs),+ )) - }; -} - -macro_rules! max { - ($x:expr) => ( $x ); - ($x:expr, $($xs:expr),+) => { - std::cmp::max($x, max!( $($xs),+ )) - }; -} - -macro_rules! first_ident { - ($x:ident) => { - $x - }; - ($x:ident, $($xs:ident),+) => { - $x - }; -} - -macro_rules! command_impl { - ($arg:expr, $t1:ty, $t2:ident, |$state:ident, $x:ident| $action:expr) => { - CommandDesc::new( - $arg.value_parser(value_parser!($t1)).num_args(1).display_order(order()), - #[allow(unused_variables)] - &|val, $state| { - if let CommandValue::$t2($x) = val { - $action; - Ok(()) - } else { - unreachable!("Clap ensure types are correct") - } - }, - ) - }; - ($arg:expr, $t1:ty, $t2:ident, $(|$state:ident, $($x:ident),+| $action:expr),+) => { - CommandDesc::new( - $arg - .value_parser(value_parser!($t1)) - .num_args(min!($(count_items!($($x),+)),+)..=max!($(count_items!($($x),+)),+)) - .display_order(order()), - &|val, first_ident!($($state),+)| match val.try_vector()? { - $([$(CommandValue::$t2($x)),+] => { - $action; - Ok(()) - }),+ - _ => unreachable!("Clap ensure types are correct"), - }, - ) - }; -} - -macro_rules! command_decl { - ($arg:expr, f64, $(|$state:ident, $($x:ident),+| $action:expr),+) => { - command_impl!($arg, f64, Float, $(|$state, $($x),+| $action),+) - }; - ($arg:expr, bool, $(|$state:ident, $($x:ident),+| $action:expr),+) => { - command_impl!($arg, bool, Bool, $(|$state, $($x),+| $action),+) - }; - ($arg:expr, String, $(|$state:ident, $($x:ident),+| $action:expr),+) => { - command_impl!($arg, String, String, $(|$state, $($x),+| $action),+) - }; - ($arg:expr, LayerID, $(|$state:ident, $($x:ident),+| $action:expr),+) => { - command_impl!($arg, vsvg::LayerID, LayerID, $(|$state, $($x),+| $action),+) - }; -} - -// this needs to be implemented this way such as to be available from the macros -fn order() -> usize { - static mut ORDER: usize = 0; - unsafe { - ORDER += 1; - ORDER - } -} - -pub(crate) fn command_list() -> HashMap> { - [ - command_decl!( - arg!(-t --translate [X] "Translate by provided coordinates"), - f64, - |state, tx, ty| state.document.translate(*tx, *ty) - ), - command_decl!( - Arg::new("rotate-rad") - .short('R') - .long("rotate-rad") - .value_name("X") - .help("Rotate by X radians around the origin"), - f64, - |state, angle| state.document.rotate(*angle) - ), - command_decl!( - arg!(-r --rotate [X] "Rotate by X degrees around the origin"), - f64, - |state, angle| state.document.rotate(angle.to_radians()) - ), - command_decl!( - arg!(-s --scale [X] "Uniform (X) or non-uniform (X Y) scaling around the origin"), - f64, - |state, s| state.document.scale(*s), - |state, sx, sy| state.document.scale_non_uniform(*sx, *sy) - ), - command_decl!( - Arg::new("scale-around") - .long("scale-around") - .value_name("X") - .help("Scale around the provided point"), - f64, - |state, sx, sy, px, py| state.document.scale_around(*sx, *sy, *px, *py) - ), - command_decl!( - arg!(-c --crop [X] "Crop to provided XMIN, YMIN, XMAX, YMAX"), - f64, - |state, a, b, c, d| state.document.crop(*a, *b, *c, *d) - ), - command_decl!( - arg!(--linesort "Reorder paths to minimize pen-up distance"), - bool, - |state, b| state.document.for_each(|layer| layer.sort(*b)) - ), - command_decl!( - arg!(--layermerge "Merge all layers into layer 1"), - bool, - |state, b| state.document.merge_layers() - ), - command_decl!( - arg!(--strokewidth "Force stroke width to provided value"), - f64, - |state, w| state.document.for_each( - |layer| layer.for_each( - |path| path.metadata_mut().stroke_width = *w - ) - ) - ), - command_decl!( - arg!(--flatten "Flatten all curves to line segments with the provided tolerance"), - f64, - |state, tol| state.document = state.document.flatten(*tol).into() - ), - command_decl!( - arg!(--dlayer [X] "Set target layer for draw operations"), - LayerID, - |state, lid| state.draw_layer = *lid - ), - command_decl!( - arg!(--dtranslate [X] "Apply an X, Y translation to the current transform"), - f64, - |state, dx, dy| state.draw_state.translate(*dx, *dy) - ), - command_decl!( - arg!(--drotate [X] "Apply a rotation to the current transform"), - f64, - |state, angle| state.draw_state.rotate(angle.to_radians()) - ), - command_decl!( - arg!(--dscale [X] "Apply a uniform (X) or non-uniform (X, Y) scale to the current transform"), - f64, - |state, s| state.draw_state.scale(*s), - |state, sx, sy| state.draw_state.scale_non_uniform(*sx, *sy) - ), - command_decl!( - arg!(--dskew [X] "Apply a (X, Y) skew to the current transform"), - f64, - |state, sx, sy| state.draw_state.skew(sx.to_radians(), sy.to_radians()) - ), - command_decl!( - arg!(--dcbez [X] "Draw a cubic bezier curve with X, Y, X1, Y1, X2, Y2, X3, Y3"), - f64, - |state, x1, y1, x2, y2, x3, y3, x4, y4| { - state - .draw() - .cubic_bezier(*x1, *y1, *x2, *y2, *x3, *y3, *x4, *y4) - } - ), - command_decl!( - arg!(--dqbez [X] "Draw a quadratic bezier curve with X, Y, X1, Y1, X2, Y2"), - f64, - |state, x1, y1, x2, y2, x3, y3| { - state - .draw() - .quadratic_bezier(*x1, *y1, *x2, *y2, *x3, *y3) - } - ), - command_decl!( - arg!(--darc [X] "Draw an arc with X, Y, RX, XY, START, SWEEP, ROT_X"), - f64, - |state, x, y, rx, ry, start, sweep, rot_x| { - state - .draw() - .arc(*x, *y, *rx, *ry, start.to_radians(), sweep.to_radians(), rot_x.to_radians()) - } - ), - command_decl!( - arg!(--dcircle "Draw a circle with X, Y, R"), - f64, - |state, x, y, r| { - state - .draw() - .circle(*x, *y, *r) - } - ), - command_decl!( - arg!(--dellipse "Draw an ellipse with X, Y, RX, RY, ROT_X"), - f64, - |state, x, y, rx, ry, rot_x| { - state - .draw() - .ellipse(*x, *y, *rx, *ry, rot_x.to_radians()) - } - ), - command_decl!( - arg!(--dline [X] "Draw a line with X, Y, X1, Y1"), - f64, - |state, x1, y1, x2, y2| { - state - .draw() - .line(*x1, *y1, *x2, *y2) - } - ), - command_decl!( - arg!(--drect [X] "Draw a rectangle with X, Y, W, H"), - f64, - |state, a, b, c, d| { - state.draw().rect(*a, *b, *c, *d) - } - ), - command_decl!( - arg!(--drrect [X] "Draw a rounded rectangle with X, Y, W, H, TL, TR, BR, BL"), - f64, - |state, cx, cy, w, h, tl, tr, br, bl| { - state - .draw() - .rounded_rect(*cx, *cy, *w, *h, *tl, *tr, *br, *bl) - } - ), - command_decl!( - arg!(--dsvg [X] "Draw from an SVG path representation"), - String, - |state, path| { - state - .draw() - .svg_path(path)? - } - ), - command_decl!( - arg!(--write [FILE] "Write the current document to a file"), - String, - |state, file| { - if file == "-" { - state.document.to_svg(std::io::stdout())?; - } else { - let file = std::io::BufWriter::new(std::fs::File::create(file)?); - state.document.to_svg(file)?; - } - } - ), - command_decl!( - arg!(--stats "Print stats"), - bool, - |state, b| { - println!("Stats: {:?}", state.document.stats()); - println!("Bounds: {:?}", state.document.bounds()); - } - ), - ] - .into_iter() - .map(|c| (c.id.clone(), c)) - .collect() -} diff --git a/crates/vsvg-cli/src/commands/context.rs b/crates/vsvg-cli/src/commands/context.rs new file mode 100644 index 0000000..e559dcb --- /dev/null +++ b/crates/vsvg-cli/src/commands/context.rs @@ -0,0 +1,32 @@ +use bpaf::{construct, Bpaf, Parser}; + +use vsvg::LayerID; + +use crate::commands::{make_command_parser, Command, DynCommand, State}; + +pub(crate) fn parser() -> impl Parser { + let layer = make_command_parser(layer()); + construct!([layer]).group_help("Context:") +} + +/// Select which layer(s) to operate on +/// +/// +/// This command sets the target layer(s) on which subsequent command will operate. Without +/// argument, this command resets the context and subsequent commands will operate on all layers. +/// With one or more layer IDs, this command will set the context to the specified layers. +/// Note that not all commands support all contexts. Some require a single layer, while other ignore +/// it altogether. +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("layer"), adjacent)] +struct Layer { + #[bpaf(positional("LID"), many, catch)] + lids: Vec, +} + +impl Command for Layer { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.layer_context = self.lids.iter().copied().collect(); + Ok(()) + } +} diff --git a/crates/vsvg-cli/src/commands/draw.rs b/crates/vsvg-cli/src/commands/draw.rs new file mode 100644 index 0000000..6c6d691 --- /dev/null +++ b/crates/vsvg-cli/src/commands/draw.rs @@ -0,0 +1,184 @@ +use bpaf::{construct, positional, Bpaf, Parser}; + +use vsvg::{Angle, Draw, Length, Transforms}; + +use crate::commands::{ + make_command_parser, + utils::{pivot, Pivot}, + Command, DynCommand, State, +}; + +pub(crate) fn parser() -> impl Parser { + let d_translate = make_command_parser(d_translate()); + let d_rotate = make_command_parser(d_rotate()); + let d_scale = make_command_parser(d_scale()); + let line = make_command_parser(line()); + let circle = make_command_parser(circle()); + let ellipse = make_command_parser(ellipse()); + construct!([d_translate, d_rotate, d_scale, line, circle, ellipse]).group_help("Draw commands:") +} + +// ================================================================================================= +// Drawing state + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("dtranslate"), adjacent)] +struct DTranslate { + #[bpaf(positional("TX"))] + tx: Length, + #[bpaf(positional("TY"))] + ty: Length, +} + +impl Command for DTranslate { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.draw_state.translate(self.tx, self.ty); + Ok(()) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("drotate"), adjacent)] +struct DRotate { + #[bpaf(external, optional)] + pivot: Option, + + #[bpaf(positional("ANGLE"))] + angle: Angle, +} + +impl Command for DRotate { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + if let Some(pivot) = &self.pivot { + state.draw_state.rotate_around(self.angle, pivot.x, pivot.y); + } else { + state.draw_state.rotate(self.angle); + } + + Ok(()) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("dscale"), adjacent)] +struct DScale { + #[bpaf(external, optional)] + pivot: Option, + + #[bpaf(positional("SX"))] + sx: Length, + + #[bpaf(positional("SY"), optional, catch)] + sy: Option, +} + +impl Command for DScale { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + if let Some(pivot) = &self.pivot { + state + .draw_state + .scale_around(self.sx, self.sy.unwrap_or(self.sx), pivot.x, pivot.y); + } else if let Some(sy) = self.sy { + state.draw_state.scale_non_uniform(self.sx, sy); + } else { + state.draw_state.scale(self.sx); + } + + Ok(()) + } +} + +// ================================================================================================= +// Drawing primitives + +fn extra_coords() -> impl Parser> { + let x = positional::("X3"); + let y = positional::("Y3"); + construct!(x, y).many().catch() +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Line { + #[bpaf(short, long)] + close: bool, + + #[bpaf(positional("X1"))] + x1: Length, + #[bpaf(positional("Y1"))] + y1: Length, + #[bpaf(positional("X2"))] + x2: Length, + #[bpaf(positional("Y2"))] + y2: Length, + #[bpaf(external)] + extra_coords: Vec<(Length, Length)>, +} + +impl Command for Line { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.draw(|draw| { + let first_points = [(self.x1, self.y1), (self.x2, self.y2)]; + let points = first_points + .iter() + .chain(&self.extra_coords) + .map(|(x, y)| vsvg::Point::new(x, y)); + + draw.polyline(points, self.close); + Ok(()) + }) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Circle { + #[bpaf(positional("CX"))] + cx: Length, + #[bpaf(positional("CY"))] + cy: Length, + #[bpaf(positional("R"))] + r: Length, +} + +impl Command for Circle { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.draw(|draw| { + draw.circle(self.cx, self.cy, self.r); + + Ok(()) + }) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Ellipse { + #[bpaf(short, long)] + rotation: Option, + + #[bpaf(positional("CX"))] + cx: Length, + #[bpaf(positional("CY"))] + cy: Length, + #[bpaf(positional("RX"))] + rx: Length, + #[bpaf(positional("RY"))] + ry: Length, +} + +impl Command for Ellipse { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.draw(|draw| { + draw.ellipse( + self.cx, + self.cy, + self.rx, + self.ry, + self.rotation.unwrap_or_default(), + ); + + Ok(()) + }) + } +} diff --git a/crates/vsvg-cli/src/commands/io.rs b/crates/vsvg-cli/src/commands/io.rs new file mode 100644 index 0000000..d450b07 --- /dev/null +++ b/crates/vsvg-cli/src/commands/io.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use bpaf::{construct, Bpaf, Parser}; + +use vsvg::DocumentTrait; + +use crate::commands::{make_command_parser, Command, DynCommand, State}; + +pub(crate) fn parser() -> impl Parser { + let read = make_command_parser(read()); + let write = make_command_parser(write()); + let show = make_command_parser(show()); + + construct!([read, write, show]).group_help("I/O commands:") +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Read { + #[bpaf(short, long)] + single_layer: bool, + + #[bpaf(positional("PATH"))] + path: PathBuf, +} + +impl Command for Read { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let new_doc = vsvg::Document::from_svg(&self.path, self.single_layer).unwrap(); + state.document = new_doc; + Ok(()) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Write { + #[bpaf(positional("PATH"))] + path: PathBuf, +} + +impl Command for Write { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.document.to_svg_file(&self.path)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Show {} + +impl Command for Show { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + vsvg_viewer::show(Arc::new(state.document.clone())) + } +} diff --git a/crates/vsvg-cli/src/commands/layers.rs b/crates/vsvg-cli/src/commands/layers.rs new file mode 100644 index 0000000..8ceab6b --- /dev/null +++ b/crates/vsvg-cli/src/commands/layers.rs @@ -0,0 +1,76 @@ +use bpaf::{construct, Bpaf, Parser}; + +use vsvg::{DocumentTrait as _, LayerID, LayerTrait as _}; + +use crate::commands::{make_command_parser, Command, DynCommand, State}; + +pub(crate) fn parser() -> impl Parser { + let layer_delete = make_command_parser(layer_delete()); + let layer_copy = make_command_parser(layer_copy()); + construct!([layer_delete, layer_copy]).group_help("Layers:") +} + +// TODO: LIDs passed as arguement for convenience—it's weird to use `layer 1 2 3 ldelete`. Maybe +// there should be a "generic" delete command (which uses the context) along these layer-spacific +// ones. + +/// Delete the specified layers +/// +/// This command deletes everything if no layers are specified at all. +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("ldelete"), adjacent)] +struct LayerDelete { + /// Delete the layers selected by the `layer` command + #[bpaf(short, long)] + selected: bool, + + /// Layer(s) to delete (ignored if `--selected` is used) + #[bpaf(positional("LID"), many, catch)] + lids: Vec, +} + +impl Command for LayerDelete { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + if self.selected { + //TODO: warn if `lids` is not empty + + for layer_id in state.layers() { + state.document.remove(layer_id); + } + } else if self.lids.is_empty() { + state.document.clear(); + } else { + for layer_id in &self.lids { + state.document.remove(*layer_id); + } + } + + Ok(()) + } +} + +/// Copy the specified layer(s) to the target layer +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("lcopy"), adjacent)] +struct LayerCopy { + #[bpaf(positional("TARGET"))] + target: LayerID, +} + +impl Command for LayerCopy { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let mut destination_layer = state.document.get_mut(self.target).clone(); + + state.for_layer(|layer, _| { + destination_layer.merge(layer); + Ok(()) + })?; + + *state.document.get_mut(self.target) = destination_layer; + + Ok(()) + } +} + +//TODO: lmerge +//TODO: lmove diff --git a/crates/vsvg-cli/src/commands/mod.rs b/crates/vsvg-cli/src/commands/mod.rs new file mode 100644 index 0000000..0988e78 --- /dev/null +++ b/crates/vsvg-cli/src/commands/mod.rs @@ -0,0 +1,120 @@ +use std::collections::BTreeSet; +use std::fmt::Debug; + +use bpaf::Parser; + +use vsvg::{Document, DocumentTrait, Layer, LayerID}; + +use crate::draw_state::{DrawState, LayerDrawer}; + +pub(crate) mod context; +pub(crate) mod draw; +pub(crate) mod io; +pub(crate) mod layers; +pub(crate) mod ops; +pub(crate) mod transforms; +pub(crate) mod utils; + +#[derive(thiserror::Error, Debug, Clone)] +pub(crate) enum CommandError { + #[error("Expected a single layer in context (see `layer` command)")] + ExpectSingleLayer, +} + +#[derive(Default, Debug)] +pub(crate) struct State { + pub document: Document, + pub draw_state: DrawState, + + /// Current layer context. + /// + /// When empty, all layers are active. + pub layer_context: BTreeSet, +} + +impl State { + fn check_single_layer( + &mut self, + func: impl FnOnce(&mut Self, LayerID) -> anyhow::Result, + ) -> anyhow::Result { + let layer_id = self + .layer_context + .first() + .copied() + .ok_or(CommandError::ExpectSingleLayer)?; + + if self.layer_context.len() > 1 { + return Err(CommandError::ExpectSingleLayer.into()); + } + + func(self, layer_id) + } + + //TODO: revive if needed + // pub(crate) fn single_layer( + // &mut self, + // func: impl FnOnce(&mut Layer, LayerID) -> anyhow::Result, + // ) -> anyhow::Result { + // self.check_single_layer(|state, layer_id| func(state.document.get_mut(layer_id), layer_id)) + // } + + /// Get the selected layers. + pub(crate) fn layers(&self) -> Vec { + if self.layer_context.is_empty() { + self.document.layers().keys().copied().collect() + } else { + self.layer_context.iter().copied().collect() + } + } + + //TODO: this creates selected layer than don't exists, not always desirable! + pub(crate) fn iter_layers(&mut self) -> impl Iterator + '_ { + self.document + .layers_mut() + .iter_mut() + .filter_map(|(id, layer)| { + if self.layer_context.is_empty() || self.layer_context.contains(id) { + Some((layer, *id)) + } else { + None + } + }) + } + + pub(crate) fn for_layer( + &mut self, + mut func: impl FnMut(&mut Layer, LayerID) -> anyhow::Result<()>, + ) -> anyhow::Result<()> { + for (layer, layer_id) in self.iter_layers() { + func(layer, layer_id)?; + } + + Ok(()) + } + + pub(crate) fn draw( + &mut self, + func: impl FnOnce(&mut LayerDrawer) -> anyhow::Result, + ) -> anyhow::Result { + self.check_single_layer(|state, layer_id| { + func(&mut LayerDrawer { + state: &state.draw_state, + layer: state.document.get_mut(layer_id), + }) + }) + } +} + +pub(crate) trait Command: Debug { + fn execute(&self, state: &mut State) -> anyhow::Result<()>; +} + +/// Parser for one or more command. +pub(crate) type DynCommand = Box; + +/// Transform a concrete [`Command`] parser into a generic [`DynCommand`] parser. +pub(crate) fn make_command_parser( + parser: impl Parser, +) -> impl Parser { + parser.map(|t| Box::new(t) as DynCommand) +} diff --git a/crates/vsvg-cli/src/commands/ops.rs b/crates/vsvg-cli/src/commands/ops.rs new file mode 100644 index 0000000..8b6c63a --- /dev/null +++ b/crates/vsvg-cli/src/commands/ops.rs @@ -0,0 +1,73 @@ +use bpaf::{construct, Bpaf, Parser}; + +use vsvg::Length; + +use crate::commands::{make_command_parser, Command, DynCommand, State}; + +pub(crate) fn parser() -> impl Parser { + let crop = make_command_parser(crop()); + let line_sort = make_command_parser(line_sort()); + construct!([crop, line_sort]).group_help("Operations:") +} + +/// Crop geometries of the selected layer(s) to the provided bounds +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Crop { + /// DX and DY are absolute coordinates instead of width/height + #[bpaf(short, long)] + absolute: bool, + + /// X coordinate of the top-left corner + #[bpaf(positional("X"))] + x: Length, + + /// Y coordinate of the top-left corner + #[bpaf(positional("Y"))] + y: Length, + + /// Width of the crop rectangle (or X coordinate of the bottom-right corner with `-a`) + #[bpaf(positional("DX"))] + dx: Length, + + /// Height of the crop rectangle (or Y coordinate of the bottom-right corner with `-a`) + #[bpaf(positional("DY"))] + dy: Length, +} + +impl Command for Crop { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let (x_max, y_max) = if self.absolute { + (self.dx, self.dy) + } else { + (self.x + self.dx, self.y + self.dy) + }; + + state.for_layer(|layer, _| { + layer.crop(self.x, self.y, x_max, y_max); + Ok(()) + }) + } +} + +/// Sort paths within the selected layer(s) to minimize pen-up distance +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command("linesort"), adjacent)] +struct LineSort { + /// Do not allow flipping the path direction. + #[bpaf(short, long)] + no_flip: bool, +} + +impl Command for LineSort { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.for_layer(|layer, _| { + layer.sort(!self.no_flip); + Ok(()) + }) + } +} + +//TODO: linemerge +//TODO: linesimplify +//TODO: flatten diff --git a/crates/vsvg-cli/src/commands/transforms.rs b/crates/vsvg-cli/src/commands/transforms.rs new file mode 100644 index 0000000..b1e2115 --- /dev/null +++ b/crates/vsvg-cli/src/commands/transforms.rs @@ -0,0 +1,91 @@ +use bpaf::{construct, Bpaf, Parser}; + +use vsvg::{Angle, Length, Transforms}; + +use crate::commands::{ + make_command_parser, + utils::{pivot, Pivot}, + Command, DynCommand, State, +}; + +/// Parser for this group of commands. +pub(crate) fn parser() -> impl Parser { + let translate = make_command_parser(translate()); + let rotate = make_command_parser(rotate()); + let scale = make_command_parser(scale()); + + construct!([translate, rotate, scale]).group_help("Transform commands:") +} + +/// Translate geometries +/// +/// +/// This command translates the geometries by TX horizontal (positive right) and TY vertical +/// (positive down). Both TX and TY may have units (default is pixels). +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Translate { + #[bpaf(positional("TX"))] + tx: Length, + #[bpaf(positional("TY"))] + ty: Length, +} + +impl Command for Translate { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + state.document.translate(self.tx, self.ty); + Ok(()) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Rotate { + #[bpaf(external, optional)] + pivot: Option, + + //TODO: why is that again?? + #[bpaf(any::<_>("ANGLE", Some))] + angle: Angle, +} + +impl Command for Rotate { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + if let Some(pivot) = &self.pivot { + state.document.rotate_around(self.angle, pivot.x, pivot.y); + } else { + state.document.rotate(self.angle); + } + + Ok(()) + } +} + +#[derive(Clone, Debug, Bpaf)] +#[bpaf(command, adjacent)] +struct Scale { + #[bpaf(external, optional)] + pivot: Option, + + #[bpaf(any::<_>("SX", Some))] + sx: f64, + + #[bpaf(any::<_>("SY", Some), optional, catch)] + sy: Option, +} + +impl Command for Scale { + fn execute(&self, state: &mut State) -> anyhow::Result<()> { + if let Some(pivot) = &self.pivot { + state + .document + .scale_around(self.sx, self.sy.unwrap_or(self.sx), pivot.x, pivot.y); + } else if let Some(sy) = self.sy { + state.document.scale_non_uniform(self.sx, sy); + } else { + state.document.scale(self.sx); + } + + Ok(()) + } +} diff --git a/crates/vsvg-cli/src/commands/utils.rs b/crates/vsvg-cli/src/commands/utils.rs new file mode 100644 index 0000000..08170d6 --- /dev/null +++ b/crates/vsvg-cli/src/commands/utils.rs @@ -0,0 +1,17 @@ +use bpaf::Bpaf; +use vsvg::Length; + +#[derive(Debug, Clone, Bpaf)] +#[bpaf(adjacent)] +pub(crate) struct Pivot { + /// Pivot point + #[bpaf(short, long)] + #[allow(dead_code)] + pivot: (), + + #[bpaf(any::<_>("X", Some))] + pub(crate) x: Length, + + #[bpaf(any::<_>("Y", Some))] + pub(crate) y: Length, +} diff --git a/crates/vsvg-cli/src/main.rs b/crates/vsvg-cli/src/main.rs index 9c08e3f..fd05fdf 100644 --- a/crates/vsvg-cli/src/main.rs +++ b/crates/vsvg-cli/src/main.rs @@ -2,61 +2,14 @@ mod cli; mod commands; mod draw_state; -use crate::commands::command_list; - use std::error::Error; -use std::io::Read; -use std::path::PathBuf; -use std::sync::Arc; - -use crate::cli::State; -use vsvg::Document; #[cfg(feature = "dhat-heap")] #[global_allocator] static ALLOC: dhat::Alloc = dhat::Alloc; fn main() -> Result<(), Box> { - #[cfg(feature = "dhat-heap")] - let _profiler = dhat::Profiler::new_heap(); - - let commands = command_list(); - let mut matches = cli::cli(&commands).get_matches(); - - // remove global args - let path = matches - .remove_one::("PATH") - .expect("PATH is a required arg"); - let no_show = matches.remove_one::("no-show").unwrap(); - let verbose = matches.remove_one::("verbose").unwrap(); - let single_layer = matches.remove_one::("single-layer").unwrap(); - - if verbose { - tracing_subscriber::fmt::init(); - } - - // create and process document - let mut state = State { - document: if path == PathBuf::from("-") { - let mut s = String::new(); - std::io::stdin().read_to_string(&mut s)?; - Document::from_string(s.as_str(), single_layer)? - } else { - Document::from_svg(path, single_layer)? - }, - ..Default::default() - }; - - let values = cli::CommandValue::from_matches(&matches, &commands); - for (id, value) in &values { - let command_desc = commands.get(id).expect("id came from matches"); - (command_desc.action)(value, &mut state)?; - } - - // display gui - if !no_show { - vsvg_viewer::show(Arc::new(state.document))?; - } + cli::cli()?; Ok(()) } diff --git a/crates/vsvg/src/document/mod.rs b/crates/vsvg/src/document/mod.rs index fe9dc07..c158765 100644 --- a/crates/vsvg/src/document/mod.rs +++ b/crates/vsvg/src/document/mod.rs @@ -21,6 +21,14 @@ pub trait DocumentTrait, P: PathTrait, D: PathDataTrait>: fn layers_mut(&mut self) -> &mut BTreeMap; + fn clear(&mut self) { + self.layers_mut().clear(); + } + + fn remove(&mut self, id: LayerID) { + self.layers_mut().remove(&id); + } + fn metadata(&self) -> &DocumentMetadata; fn metadata_mut(&mut self) -> &mut DocumentMetadata;