mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -06:00
Merge PR #303 from hoblin: deckcraft-cli help explains the CLI to an agent
# Conflicts: # apps/deckcraft-cli/src/main.rs # crates/engine/src/cmd/design.rs # crates/engine/src/cmd/file.rs # crates/engine/src/cmd/format.rs # crates/engine/src/tests.rs
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -228,6 +229,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
pub struct Subcommand {
|
||||
pub name: &'static str,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
}
|
||||
|
||||
pub const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "info",
|
||||
usage: "info FILE",
|
||||
summary: "print a presentation's slides, layouts and theme as JSON",
|
||||
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
|
||||
notes, transition), its layouts, theme fonts, sections and slide size.
|
||||
|
||||
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
|
||||
|
||||
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
usage: "convert IN OUT",
|
||||
summary: "save a presentation in the format OUT's extension names",
|
||||
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
|
||||
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide. Any other
|
||||
extension is written as .deckcraft. Prints OUT.
|
||||
|
||||
deckcraft-cli convert deck.deckcraft deck.pptx",
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
|
||||
current slide as .png or .jpg, or the presentation as .pdf or .pptx.
|
||||
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
--cmd 'format.size={\"size\":28}' \\
|
||||
--save q3.deckcraft
|
||||
|
||||
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
|
||||
parameters.
|
||||
|
||||
What the ids don't say:
|
||||
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
|
||||
[x, y, width, height].
|
||||
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
|
||||
slide.go {\"index\": N} makes slide N current.
|
||||
- Shape ids are looked up on the current slide only.
|
||||
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
|
||||
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
|
||||
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
|
||||
|
||||
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
|
||||
the id of an inserted shape.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "commands",
|
||||
usage: "commands [FILTER]",
|
||||
summary: "list the commands run can use, one per line",
|
||||
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
|
||||
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
|
||||
|
||||
deckcraft-cli commands format.
|
||||
|
||||
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
|
||||
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "describe",
|
||||
usage: "describe COMMAND",
|
||||
summary: "explain one command and its parameters",
|
||||
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
|
||||
calls it.
|
||||
|
||||
deckcraft-cli describe shape.insert",
|
||||
},
|
||||
Subcommand {
|
||||
name: "app",
|
||||
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
|
||||
summary: "run a command in the DeckCraft app started with --control",
|
||||
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
|
||||
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
|
||||
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
|
||||
|
||||
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
|
||||
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample]",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "links",
|
||||
usage: "links",
|
||||
summary: "print the DeckCraft community and project links",
|
||||
details: "Prints the DeckCraft community and project links.",
|
||||
},
|
||||
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
|
||||
];
|
||||
|
||||
pub fn page(name: &str) -> Option<String> {
|
||||
let name = match name {
|
||||
"help" | "--help" | "-h" => return Some(overview()),
|
||||
"--version" | "-V" => "version",
|
||||
name => name,
|
||||
};
|
||||
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
|
||||
}
|
||||
|
||||
pub fn overview() -> String {
|
||||
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
|
||||
let list: Vec<String> =
|
||||
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
|
||||
format!(
|
||||
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
|
||||
|
||||
{}
|
||||
|
||||
A presentation is edited by running commands on it:
|
||||
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
+108
-59
@@ -1,22 +1,9 @@
|
||||
//! Headless DeckCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||||
//! N starts at 0. --all writes slide-01.png, slide-02.png, …
|
||||
//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s)
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||||
//! deckcraft-cli describe ID # one command
|
||||
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
|
||||
//! deckcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio
|
||||
//! deckcraft-cli bench [FILE | --sample] [--runs N] [--json] # time open, render, save, export
|
||||
//! deckcraft-cli links | --version
|
||||
//! ```
|
||||
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
@@ -35,23 +22,28 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||||
N starts at 0 (the first slide). --all writes slide-01.png, slide-02.png, …
|
||||
deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png
|
||||
deckcraft-cli convert IN OUT
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
deckcraft-cli commands [FILTER]
|
||||
deckcraft-cli describe COMMAND
|
||||
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
|
||||
deckcraft-cli mcp [--connect PORT] [--sample]
|
||||
deckcraft-cli bench [FILE | --sample] [--runs N] [--json]
|
||||
deckcraft-cli links | --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
|
||||
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
|
||||
outln!("{page}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("help" | "--help" | "-h") => match rest.first() {
|
||||
None => {
|
||||
outln!("{}", help::overview());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some(name) => match help::page(name) {
|
||||
Some(page) => {
|
||||
outln!("{page}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => unknown(name),
|
||||
},
|
||||
},
|
||||
Some("--version" | "-V" | "version") => {
|
||||
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
@@ -62,24 +54,17 @@ fn main() -> ExitCode {
|
||||
Some("render-transition") => report(render_transition(rest)),
|
||||
Some("convert") => report(convert(rest)),
|
||||
Some("run") => report(run(rest)),
|
||||
Some("--help" | "-h" | "help") => {
|
||||
outln!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("commands") if rest.first().is_some_and(|a| is_help(a)) => {
|
||||
outln!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("commands") => {
|
||||
let s = Session::new();
|
||||
let f = rest.first().map(|x| x.to_lowercase());
|
||||
let list: Vec<Value> = s
|
||||
.commands()
|
||||
.into_iter()
|
||||
for c in deckcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||||
.collect();
|
||||
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||||
{
|
||||
match c.params.strip_prefix("{}") {
|
||||
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
|
||||
None => outln!("{} — {}: {}", c.id, c.label, c.params),
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("describe") => report(describe(rest.first().map(String::as_str))),
|
||||
@@ -90,11 +75,8 @@ fn main() -> ExitCode {
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{USAGE}");
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Some(other) => unknown(other),
|
||||
None => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +84,22 @@ fn is_help(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
|
||||
/// The help page of subcommand `name`, else the overview.
|
||||
fn help_page(name: &str) -> String {
|
||||
help::page(name).unwrap_or_else(help::overview)
|
||||
}
|
||||
|
||||
fn unknown(sub: &str) -> ExitCode {
|
||||
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
|
||||
usage()
|
||||
}
|
||||
|
||||
fn usage() -> ExitCode {
|
||||
eprintln!("{}", help::overview());
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
fn report(r: Result<(), String>) -> ExitCode {
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -226,7 +224,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("info"));
|
||||
return Ok(());
|
||||
}
|
||||
let path = args.first().ok_or("info needs a FILE")?;
|
||||
@@ -254,7 +252,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
"--edit" => edit = true,
|
||||
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("render"));
|
||||
return Ok(());
|
||||
}
|
||||
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
|
||||
@@ -391,7 +389,7 @@ fn render_transition(args: &[String]) -> Result<(), String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("convert"));
|
||||
return Ok(());
|
||||
}
|
||||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||||
@@ -430,12 +428,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--print" => print = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("run"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
@@ -467,15 +465,66 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
|
||||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||||
if id.is_some_and(is_help) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("describe"));
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or("describe needs a command id")?;
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
|
||||
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
|
||||
let mut head = format!("{}: {}", c.id, c.label);
|
||||
if !c.menu.is_empty() {
|
||||
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
|
||||
}
|
||||
if let Some(k) = c.shortcut {
|
||||
head.push_str(&format!(", shortcut {k}"));
|
||||
}
|
||||
outln!("{head}.\n");
|
||||
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
|
||||
let parsed = deckcraft_engine::cmd::parse_params(c.params);
|
||||
match &parsed {
|
||||
Err(_) => outln!("Parameters: {}", args.trim()),
|
||||
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
|
||||
Ok(params) => print_params(params),
|
||||
}
|
||||
let params = parsed.unwrap_or_default();
|
||||
if let Some(acts) = c.acts_on() {
|
||||
outln!("{acts}");
|
||||
}
|
||||
let remark = remark(args);
|
||||
if !remark.is_empty() {
|
||||
outln!("Note: {remark}");
|
||||
}
|
||||
if !answer.trim().is_empty() {
|
||||
outln!("Answers with {}.", answer.trim());
|
||||
}
|
||||
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
|
||||
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
|
||||
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
|
||||
let name = |p: &deckcraft_engine::cmd::Param| {
|
||||
let or = if p.or { "or " } else { "" };
|
||||
let optional = if p.optional { ", optional" } else { "" };
|
||||
format!("{or}{}{optional}", p.name)
|
||||
};
|
||||
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
|
||||
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
|
||||
outln!("Parameters:");
|
||||
for (k, p) in params.iter().enumerate() {
|
||||
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
|
||||
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
|
||||
}
|
||||
let indent = if sets { " " } else { " " };
|
||||
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
fn remark(args: &str) -> &str {
|
||||
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
|
||||
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_mcp::{Backend, Remote, control_addr};
|
||||
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
|
||||
@@ -487,7 +536,7 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
|
||||
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("app"));
|
||||
return Ok(());
|
||||
}
|
||||
other => rest.push(other.to_string()),
|
||||
@@ -519,7 +568,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("mcp"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(o: &Output) -> String {
|
||||
assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr));
|
||||
String::from_utf8_lossy(&o.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(o: &Output) -> String {
|
||||
assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout));
|
||||
String::from_utf8_lossy(&o.stderr).into_owned()
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: [&str; 9] = ["info", "render", "convert", "run", "commands", "describe", "app", "mcp", "links"];
|
||||
|
||||
fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
let err = stderr(&cli(&args));
|
||||
assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_subcommand_shows_the_overview_and_fails() {
|
||||
assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_subcommand_with_a_run_example() {
|
||||
for flag in ["help", "--help", "-h"] {
|
||||
let out = stdout(&cli(&[flag]));
|
||||
for sub in SUBCOMMANDS {
|
||||
assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}");
|
||||
}
|
||||
assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subcommand_answers_help_and_does_nothing_else() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for sub in SUBCOMMANDS {
|
||||
for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
|
||||
assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for args in [["help", "version"], ["--version", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}");
|
||||
}
|
||||
for args in [["help", "help"], ["help", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_help_says_what_the_ids_do_not() {
|
||||
let out = words(&stdout(&cli(&["run", "--help"])));
|
||||
for fact in [
|
||||
"COMMAND is a command id, then `=`, then the command's parameters as one JSON object.",
|
||||
"points on a 960×540 slide",
|
||||
"Slide indices start at 0.",
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_spells_out_a_command() {
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.insert"])),
|
||||
"shape.insert: Shapes, in Insert ▸ Illustrations.
|
||||
|
||||
Parameters:
|
||||
preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets)
|
||||
rect [x,y,w,h] pt
|
||||
text, optional string
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'shape.insert={…}'
|
||||
"
|
||||
);
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "format.size"])),
|
||||
"format.size: Font Size, in Home ▸ Font.
|
||||
|
||||
Parameters:
|
||||
size pt
|
||||
Acts on the selected text or shapes.
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'format.size={…}'
|
||||
"
|
||||
);
|
||||
assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
|
||||
assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.setDefault"])),
|
||||
"shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_shows_alternative_sets_of_parameters() {
|
||||
assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n"));
|
||||
let remove = stdout(&cli(&["describe", "animation.remove"]));
|
||||
assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}");
|
||||
assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}");
|
||||
assert!(stdout(&cli(&["describe", "shape.move"])).contains(
|
||||
" either:
|
||||
dx, optional pt (relative)
|
||||
dy, optional pt (relative)
|
||||
ids, optional
|
||||
or:
|
||||
x, optional pt (absolute)
|
||||
y, optional pt (absolute)
|
||||
ids, optional
|
||||
"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_lists_one_line_per_command() {
|
||||
assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n");
|
||||
}
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove),
|
||||
cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim),
|
||||
cmd!(
|
||||
"animation.timing",
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Slide Size",
|
||||
["Design", "Customize"],
|
||||
None,
|
||||
"{preset?: widescreen|standard|… | w, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
|
||||
"{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
|
||||
has_doc,
|
||||
slide_size
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3, orientation?: portrait|landscape, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|\"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
|
||||
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
|
||||
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard),
|
||||
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
|
||||
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
|
||||
]
|
||||
|
||||
@@ -41,15 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size),
|
||||
cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow),
|
||||
cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink),
|
||||
cmd!(
|
||||
"format.color",
|
||||
"Font Color",
|
||||
["Home", "Font"],
|
||||
None,
|
||||
"{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}",
|
||||
has_text_or_shapes,
|
||||
color
|
||||
),
|
||||
cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color),
|
||||
cmd!(
|
||||
"format.textTransparency",
|
||||
"Text Transparency",
|
||||
@@ -186,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Box Margins",
|
||||
["Format Shape", "Text Box"],
|
||||
None,
|
||||
"{left?, top?, right?, bottom?: pt}",
|
||||
"{left?: pt, top?: pt, right?: pt, bottom?: pt}",
|
||||
has_text_or_shapes,
|
||||
margins
|
||||
),
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct CommandSpec {
|
||||
/// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu).
|
||||
pub menu: &'static [&'static str],
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks
|
||||
/// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values
|
||||
/// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -61,6 +64,28 @@ pub struct CommandInfo {
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
/// Holds because `Session::execute` skips the selection check when `id` or `ids` is given.
|
||||
pub fn acts_on(&self) -> Option<String> {
|
||||
let keys = param_keys(self.params).unwrap_or_default();
|
||||
let named = if keys.contains(&"ids") {
|
||||
Some("the shapes in `ids`")
|
||||
} else if keys.contains(&"id") {
|
||||
Some("the shape in `id`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reason = (self.enabled)(&Session::with_new()).err()?;
|
||||
Some(match (reason.as_str(), named) {
|
||||
(SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(),
|
||||
(NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."),
|
||||
(NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(),
|
||||
(SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."),
|
||||
(SELECT_TWO, None) => "Acts on two or more selected shapes.".into(),
|
||||
(NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
@@ -87,6 +112,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub const NOTHING_SELECTED: &str = "nothing selected";
|
||||
pub const SELECT_TWO: &str = "select two or more objects";
|
||||
pub const NO_TEXT_POINT: &str = "no text insertion point";
|
||||
pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape";
|
||||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
|
||||
@@ -97,17 +124,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) }
|
||||
}
|
||||
/// Text editing or shapes with text selected (formatting commands apply to either).
|
||||
/// Text editing or selected shapes (formatting commands apply to either).
|
||||
pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select text or a shape".into())
|
||||
}
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) }
|
||||
}
|
||||
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
@@ -164,6 +188,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn param_keys(doc: &str) -> std::result::Result<Vec<&str>, String> {
|
||||
let mut keys = vec![];
|
||||
for p in parse_params(doc)? {
|
||||
if !keys.contains(&p.name) {
|
||||
keys.push(p.name);
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Param<'a> {
|
||||
pub name: &'a str,
|
||||
pub optional: bool,
|
||||
/// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`).
|
||||
pub about: &'a str,
|
||||
/// An alternative to the parameter before it in the same object (`color? | none?`).
|
||||
pub or: bool,
|
||||
/// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1.
|
||||
pub object: usize,
|
||||
}
|
||||
|
||||
/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset,
|
||||
/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result
|
||||
/// and is skipped. Errs where the doc strays from that grammar.
|
||||
pub fn parse_params(doc: &str) -> std::result::Result<Vec<Param<'_>>, String> {
|
||||
let doc = doc.split('→').next().unwrap_or_default();
|
||||
let mut params: Vec<Param> = vec![];
|
||||
let mut open: Vec<char> = vec![];
|
||||
let mut expect_key = false;
|
||||
let mut or = false;
|
||||
let mut objects: usize = 0;
|
||||
let mut about_from: Option<usize> = None;
|
||||
let mut chars = doc.char_indices().peekable();
|
||||
let mut prev = ' ';
|
||||
fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option<usize>, to: usize) {
|
||||
if let (Some(f), Some(p)) = (from.take(), params.last_mut()) {
|
||||
p.about = doc.get(f..to).unwrap_or_default().trim();
|
||||
}
|
||||
}
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let in_object = open == ['{'];
|
||||
match c {
|
||||
'{' | '[' | '(' => {
|
||||
open.push(c);
|
||||
if open == ['{'] {
|
||||
expect_key = true;
|
||||
objects += 1;
|
||||
or = false;
|
||||
}
|
||||
}
|
||||
'}' | ']' | ')' => {
|
||||
if in_object {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
}
|
||||
open.pop();
|
||||
}
|
||||
',' if in_object => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
}
|
||||
'|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
or = true;
|
||||
}
|
||||
c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => {
|
||||
let mut end = i + c.len_utf8();
|
||||
while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') {
|
||||
end = j + n.len_utf8();
|
||||
chars.next();
|
||||
}
|
||||
let after = doc.get(end..).unwrap_or_default();
|
||||
let rest = after.trim_start_matches('?').trim_start();
|
||||
let at = doc.len() - rest.len();
|
||||
about_from = match rest.chars().next() {
|
||||
Some(':') => Some(at + 1),
|
||||
Some('(') => Some(at),
|
||||
Some(',' | '}' | '|') => None,
|
||||
_ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::<String>())),
|
||||
};
|
||||
params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) });
|
||||
or = false;
|
||||
expect_key = false;
|
||||
}
|
||||
c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])),
|
||||
_ => {}
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -30,8 +30,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
freeform
|
||||
),
|
||||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||||
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!(
|
||||
"shape.move",
|
||||
"Move",
|
||||
[],
|
||||
None,
|
||||
"{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}",
|
||||
has_selection,
|
||||
move_by
|
||||
),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
|
||||
cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate),
|
||||
cmd!(
|
||||
@@ -95,7 +103,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
"picture.crop",
|
||||
"Crop",
|
||||
["Picture Format", "Size"],
|
||||
None,
|
||||
"{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
|
||||
has_selection,
|
||||
crop
|
||||
),
|
||||
cmd!(
|
||||
"picture.adjust",
|
||||
"Corrections",
|
||||
|
||||
@@ -27,7 +27,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s, p, "delCol"
|
||||
)),
|
||||
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
|
||||
cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split),
|
||||
cmd!(
|
||||
"table.split",
|
||||
"Split Cells",
|
||||
["Table Layout", "Merge"],
|
||||
None,
|
||||
"{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}",
|
||||
has_selection,
|
||||
split
|
||||
),
|
||||
cmd!(
|
||||
"table.style",
|
||||
"Table Styles",
|
||||
@@ -42,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Table Style Options",
|
||||
["Table Design", "Table Style Options"],
|
||||
None,
|
||||
"{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}",
|
||||
"{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}",
|
||||
has_selection,
|
||||
options
|
||||
),
|
||||
|
||||
@@ -105,7 +105,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
|
||||
|
||||
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
|
||||
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?;
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
|
||||
// Materialise an inherited placeholder box before editing (so autofit can change it).
|
||||
let r = s.edit(|doc, sel| {
|
||||
let mut t = t0.clone();
|
||||
@@ -840,7 +840,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", "select text or a shape"));
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
match &mut sh.kind {
|
||||
@@ -930,7 +930,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", "select text or a shape"));
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
match &mut sh.kind {
|
||||
|
||||
@@ -2429,3 +2429,55 @@ fn format_language_marks_the_selected_text() {
|
||||
let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect();
|
||||
assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_params_doc_names_its_keys() {
|
||||
for c in command_specs() {
|
||||
assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params));
|
||||
}
|
||||
assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]);
|
||||
assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_params_doc_spells_out_each_parameter() {
|
||||
use cmd::Param;
|
||||
let p = |name, optional, about, or, object| Param { name, optional, about, or, object };
|
||||
assert_eq!(
|
||||
cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(),
|
||||
[
|
||||
p("preset", false, "rect|roundRect", false, 0),
|
||||
p("rect", true, "[x,y,w,h] pt", false, 0),
|
||||
p("index", true, "(else selected shapes)", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(),
|
||||
[
|
||||
p("color", true, "", false, 0),
|
||||
p("none", true, "true", true, 0),
|
||||
p("gradient", true, "{stops, kind?: a|b}", false, 0),
|
||||
p("ids", true, "", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cmd::parse_params("{name} | {major, minor}").unwrap(),
|
||||
[p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_command_acts_on_names_only_keys_it_takes() {
|
||||
for c in command_specs() {
|
||||
let keys = cmd::param_keys(c.params).unwrap();
|
||||
let said = c.acts_on().unwrap_or_default();
|
||||
assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id);
|
||||
assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id);
|
||||
}
|
||||
let acts = |id: &str| find_command(id).and_then(|c| c.acts_on());
|
||||
assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes."));
|
||||
assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes."));
|
||||
assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes."));
|
||||
assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes."));
|
||||
assert_eq!(acts("slide.new"), None);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone)
|
||||
current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \
|
||||
list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \
|
||||
{layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \
|
||||
inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \
|
||||
run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \
|
||||
on `ids`. Save with save_presentation (.deckcraft or .pptx).";
|
||||
inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \
|
||||
current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \
|
||||
the selected text or shapes; shape.fill, shape.line and the other shape commands whose parameters list `ids` act on \
|
||||
those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx).";
|
||||
|
||||
/// Resource URIs.
|
||||
pub const DOC_URI: &str = "deckcraft://document";
|
||||
|
||||
@@ -12,7 +12,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
|
||||
| Method | Params | What it does |
|
||||
|---|---|---|
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) |
|
||||
@@ -30,4 +30,4 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) |
|
||||
|
||||
The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude
|
||||
and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell.
|
||||
and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell.
|
||||
|
||||
+1
-1
@@ -35,5 +35,5 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
`shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`,
|
||||
`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any
|
||||
`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe COMMAND` prints any
|
||||
command's parameters.
|
||||
|
||||
Reference in New Issue
Block a user