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:
Brandon Thomas
2026-10-14 14:36:13 -04:00
16 changed files with 623 additions and 94 deletions
+2
View File
@@ -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 |
+153
View File
@@ -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
View File
@@ -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}`")),
+139
View File
@@ -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");
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
),
+2 -2
View File
@@ -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),
]
+2 -10
View File
@@ -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
),
+124 -7
View File
@@ -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 {
+19 -3
View File
@@ -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",
+10 -2
View File
@@ -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
),
+3 -3
View File
@@ -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 {
+52
View File
@@ -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);
}
+4 -3
View File
@@ -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";
+2 -2
View File
@@ -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
View File
@@ -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.