From 503ee5d67e3014ae64496c5a3094e0538acfdcc0 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 12:38:57 +0300 Subject: [PATCH] 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 --- apps/deckcraft/src/main.rs | 84 +++++++++++++++++++++++++++------- crates/engine/src/cmd/slide.rs | 7 ++- crates/engine/src/cmd/text.rs | 10 ++-- crates/engine/src/tests.rs | 28 ++++++++++++ crates/model/src/text.rs | 15 ++++++ 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..6499e8e1 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -1,6 +1,7 @@ //! DeckCraft desktop app. //! //! Usage: `deckcraft [--control ] [--sample] [--show] [files…]` +//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits. //! //! `--control ` (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, files: Vec, sample: bool, show: bool }, +} + +/// `--help` and `--version` leave before the window and the log file. +fn startup(mut control_port: Option, args: impl IntoIterator) -> 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 { 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 = 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); + } + } +} diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..41878b34 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -108,7 +108,12 @@ fn new(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..3c0cc7ec 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -705,14 +705,10 @@ fn set(s: &mut Session, p: &Value) -> Result { 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(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..a042deb8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index b8138a39..f6bd747d 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -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() }