A tabbed slide body is an outline, and --help exits (#360)

slide.new kept a leading tab as text, while text.set already turned it into a bullet level. The desktop --help flag was opened as a missing file.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita
2026-10-13 12:38:57 +03:00
co-authored by Cursor
parent 735e16d93c
commit 503ee5d67e
5 changed files with 120 additions and 24 deletions
+68 -16
View File
@@ -1,6 +1,7 @@
//! DeckCraft desktop app.
//!
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
//!
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@@ -129,6 +130,41 @@ fn services() -> Services {
const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\
Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
--control PORT JSON control server on 127.0.0.1:PORT
--sample open the sample presentation
--show start the slide show
--help, -h print this help and exit
--version, -V print the version and exit
";
enum Startup {
Help,
Version,
Run { control_port: Option<u16>, files: Vec<String>, sample: bool, show: bool },
}
/// `--help` and `--version` leave before the window and the log file.
fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>) -> Startup {
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" | "-V" => return Startup::Version,
"--help" | "-h" => return Startup::Help,
_ => files.push(a),
}
}
Startup::Run { control_port, files, sample, show }
}
fn app_icon() -> Option<egui::IconData> {
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
@@ -138,26 +174,21 @@ fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" => {
let (control_port, files, sample, show) =
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
Startup::Help => {
print!("{USAGE}");
return Ok(());
}
Startup::Version => {
println!("deckcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show),
};
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
// The log file lives under the settings directory; opened after the arguments, so `--help` and
// `--version` leave no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
@@ -228,3 +259,24 @@ fn main() -> eframe::Result {
}),
)
}
#[cfg(test)]
mod tests {
use super::{Startup, startup};
#[test]
fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help));
assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version));
let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]);
assert!(matches!(launch, Startup::Run { .. }));
if let Startup::Run { control_port, files, sample, show } = launch {
assert_eq!(control_port, Some(4242));
assert_eq!(files, ["talk.pptx".to_string()]);
assert!(sample);
assert!(!show);
}
}
}
+6 -1
View File
@@ -108,7 +108,12 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(b) = str_param(p, "body")
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title()))
{
sh.text = Some(TextBody::from_text(b));
// Same outline rule as text.set: a leading tab is the next bullet level.
let mut text = TextBody::from_text(b);
for para in &mut text.paragraphs {
para.promote_leading_tabs();
}
sh.text = Some(text);
}
let at = usize_param(p, "at").unwrap_or(if doc.slides.is_empty() { 0 } else { sel.slide + 1 }).min(doc.slides.len());
let id = slide.id;
+3 -7
View File
@@ -705,14 +705,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect();
let mut nb = TextBody::from_text(&text);
for (i, para) in nb.paragraphs.iter_mut().enumerate() {
// Leading tabs set the outline level.
let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0);
if tabs > 0
&& let Some(r) = para.runs.first_mut()
// Leading tabs set the outline level; otherwise keep the paragraph's own level.
if !para.promote_leading_tabs()
&& let Some((lv, pp)) = levels.get(i)
{
r.text = r.text.trim_start_matches('\t').to_string();
para.level = tabs.min(8) as u8;
} else if let Some((lv, pp)) = levels.get(i) {
para.level = *lv;
para.props = pp.clone();
}
+28
View File
@@ -226,6 +226,34 @@ fn base64_roundtrip() {
assert!(cmd::base64_decode("!!").is_none());
}
#[test]
fn new_slide_body_tabs_match_text_set() {
let body = "Install from DMG\nLaunch app\n\tCheck signing\n\t\tDeep\nBuild a deck";
let mut s = session();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Test Plan", "body": body})).unwrap();
let levels = |s: &Session| {
let slide = s.doc().unwrap().current_slide().unwrap();
let sh = slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap();
let rows: Vec<(u8, String)> = sh.text.as_ref().unwrap().paragraphs.iter().map(|p| (p.level, p.text())).collect();
(sh.id.0, rows)
};
let (id, from_new) = levels(&s);
assert_eq!(
from_new,
vec![
(0, "Install from DMG".to_string()),
(0, "Launch app".to_string()),
(1, "Check signing".to_string()),
(2, "Deep".to_string()),
(0, "Build a deck".to_string()),
]
);
s.execute("text.set", &json!({"id": id, "text": body})).unwrap();
assert_eq!(levels(&s).1, from_new);
s.execute("text.set", &json!({"id": id, "text": "\t\t\t\t\t\t\t\t\tCapped"})).unwrap();
assert_eq!(levels(&s).1, vec![(8, "Capped".to_string())]);
}
#[test]
fn layout_change_keeps_text() {
let mut s = session();
+15
View File
@@ -453,6 +453,21 @@ impl Paragraph {
pub fn new(text: &str) -> Self {
Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() }
}
/// Leading tabs are the outline level (at most 8), the same rule as a typed indent.
/// Returns whether any tab was consumed.
pub fn promote_leading_tabs(&mut self) -> bool {
let Some(r) = self.runs.first_mut() else { return false };
if r.kind != RunKind::Text {
return false;
}
let tabs = r.text.chars().take_while(|c| *c == '\t').count();
if tabs == 0 {
return false;
}
r.text = r.text.trim_start_matches('\t').to_string();
self.level = tabs.min(8) as u8;
true
}
pub fn text(&self) -> String {
self.runs.iter().map(|r| r.content()).collect()
}