mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 20:16:50 -06:00
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:
+68
-16
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user