diff --git a/Cargo.lock b/Cargo.lock index 6ce46f1..205ca68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1659,6 +1659,25 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1854,6 +1873,7 @@ dependencies = [ "dirs", "eframe", "egui", + "include_dir", "minijinja", "pulldown-cmark", "rfd", diff --git a/Cargo.toml b/Cargo.toml index d27818a..8a88517 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,10 @@ ureq = { version = "2", default-features = false, features = ["tls"] } spellbook = "0.4.2" # Templating engine for project scaffolding (replaces cookiecutter dependency) minijinja = { version = "2", features = ["builtins", "loader"] } +# Embeds the bundled project template (templates/snowflake) into the binary +# at compile time, so New project… never depends on files installed beside +# the executable. +include_dir = "0.7.4" [profile.release] opt-level = 2 diff --git a/README.md b/README.md index d9565b3..b30eaa9 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,13 @@ sudo apt install libgl1 libxkbcommon0 libwayland-client0 libx11-6 ```sh install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript -# The project templates are read from disk, so they travel with the binary: -cp -r templates ~/.local/bin/templates ``` -The app looks for `templates/snowflake/cookiecutter.json` beside the executable -and then up through its parent folders, so a release build run from the source -tree finds it without installing anything. Without it the app still starts and -everything else works — only **File ▸ New project…** has nothing to scaffold -from, and says so. You can also point **Settings ▸ New project… ▸ Template** at -a copy anywhere else. +That's it — the **File ▸ New project…** template is compiled into the binary, +so there's nothing else to install alongside it. If you want to use a +customized copy of the template instead of the bundled one, point +**Settings ▸ New project… ▸ Template** at a folder of your own containing a +`cookiecutter.json`; leave it blank to keep using the bundled template. Optionally add a desktop launcher at `~/.local/share/applications/md-manuscript.desktop`: @@ -660,10 +657,13 @@ single finished document, use **Export ODT**. template and then opens it as the workspace, so you go from nothing to editing chapters in one step. -The app now uses **built-in MiniJinja templating** (no external Python -cookiecutter required) to render templates. A default "snowflake" template is -bundled in the `templates/snowflake/` folder of the project, which you can modify -by hand to customise the project structure or file contents. +The app uses **built-in MiniJinja templating** (no external Python cookiecutter +required) to render templates. A default "snowflake" template is compiled +into the binary itself — nothing has to be installed alongside it. Its source +lives in the `templates/snowflake/` folder of this repository, for anyone +building from source who wants to customise it (a rebuild picks up the +change); at runtime, point **Settings ▸ New project… ▸ Template** at a folder +of your own to use a customised copy instead, without rebuilding. The dialog asks for a **name**, **author**, **description** and the **folder to create it in** (prefilled with wherever you put the last one). Those are passed @@ -685,13 +685,15 @@ You can use your own templates by pointing the **Template** setting in **Settings ▸ New project…** at any folder containing a `cookiecutter.json` file. The template should contain a subdirectory named with a template expression (e.g., `{{ cookiecutter.project_name }}`) which becomes the project root after -rendering. +rendering. Leave the setting blank (the default) to use the bundled template; +an override that stops resolving to a valid template (folder moved, deleted) +silently falls back to the bundled one rather than failing project creation. ### Settings ▸ New project… | Setting | Meaning | |---|---| -| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to the bundled `templates/snowflake`, found beside the executable or up through its parent folders | +| **Template** | an external folder holding a `cookiecutter.json` to use instead of the bundled template. Blank (the default) uses the template compiled into the binary | | **Manuscript folder** | which folder of a project holds the book (default `05-First Draft`). Its files are ordered, numbered and exported; the rest of the project is reference. A leading number is optional, so `First Draft` also matches `05-First Draft`. A generated project always opens at its root — this does not change which folder the workspace is | | **Publish to Gitea after creation** | when enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it | | **Gitea** | server URL, user and token, used to create repositories when publishing is enabled | diff --git a/src/app/corkboard.rs b/src/app/corkboard.rs new file mode 100644 index 0000000..4be274d --- /dev/null +++ b/src/app/corkboard.rs @@ -0,0 +1,108 @@ +//! The Corkboard window: one card per chapter, showing its title and +//! synopsis, drag-reorderable — a restructuring view for the planning phase +//! that draws on the same data the Synopsis window compiles. + +use super::*; + +impl App { + /// The Corkboard window: chapter cards in manuscript order, draggable to + /// reorder the manuscript itself. + pub(super) fn corkboard_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_corkboard; + + // Collect up front so the window closure only reads. `indices[i]` is + // `rows[i]`'s real position in `self.files` — `manuscript_files()`'s + // own index numbers chapters, not flat file-list positions, so it + // can't be used directly as a `reorder` argument. + let rows = self.manuscript_synopsis_rows(); + let indices: Vec = rows + .iter() + .map(|(name, _, _)| self.files.iter().position(|f| f == name).unwrap_or(0)) + .collect(); + + let mut jump_to: Option = None; + let mut reorder_to: Option<(usize, usize)> = None; + + egui::Window::new("Corkboard") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(640.0) + .default_height(480.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new( + "One card per chapter, in manuscript order. Drag a card to \ + reorder the manuscript; click a title to open it.", + ) + .small() + .weak(), + ); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + if rows.is_empty() { + ui.label("No manuscript files yet."); + return; + } + ui.horizontal_wrapped(|ui| { + for (i, (name, title, slug)) in rows.iter().enumerate() { + let idx = indices[i]; + let card = ui + .dnd_drag_source( + egui::Id::new(("corkboard", name.as_str())), + idx, + |ui| { + ui.group(|ui| { + ui.set_width(160.0); + ui.set_min_height(110.0); + if ui + .link(egui::RichText::new(title).strong()) + .clicked() + { + jump_to = Some(name.clone()); + } + ui.add_space(4.0); + match slug.as_deref().map(str::trim) { + Some(s) if !s.is_empty() => { + ui.label(egui::RichText::new(s).small()); + } + _ => { + ui.label( + egui::RichText::new( + "— no synopsis —", + ) + .weak() + .italics() + .small(), + ); + } + } + }); + }, + ) + .response; + + drop_highlight(ui, &card); + if let Some(payload) = card.dnd_release_payload::() { + reorder_to = Some((*payload, idx)); + } + } + }); + }); + }); + + if let Some(name) = jump_to { + if let Some(sel) = self.files.iter().position(|f| *f == name) { + self.select(sel); + } + } + if let Some((from, to)) = reorder_to { + if from != to { + self.reorder(from, to); + } + } + self.show_corkboard = open; + } +} diff --git a/src/app/file_list.rs b/src/app/file_list.rs index 733c218..2d7f2fc 100644 --- a/src/app/file_list.rs +++ b/src/app/file_list.rs @@ -233,6 +233,7 @@ impl App { }); } ui.separator(); + self.snowflake_progress_section(ui); // The actions live in their own panel, claimed *before* the // list: that hands the list whatever height is left over @@ -785,7 +786,7 @@ fn file_context_menu(ui: &mut egui::Ui, name: &str, idx: usize, action: &mut Opt /// Outline a whole-row drop target while a file is dragged over it, and report /// whether it is being hovered (so the caller can look for the release). -fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool { +pub(super) fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool { if response.dnd_hover_payload::().is_none() { return false; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 6990f60..7ef1487 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,14 +20,19 @@ mod autocomplete; mod auto_snapshot; mod beats; mod characters; +mod corkboard; mod diff; mod editor; mod file_list; mod find; mod grammar; mod outline; +mod placeholders; mod project; +mod quick_capture; +mod snowflake_progress; mod spelling; +mod sprint; mod story_bible_editor; mod story_bible_ui; mod style; @@ -347,6 +352,20 @@ pub struct App { show_outline: bool, /// Whether the Synopsis window is open. show_synopsis: bool, + /// Whether the Sprint window is open. + show_sprint: bool, + /// Whether the Placeholders window is open. + show_placeholders: bool, + /// Whether the Corkboard window is open. + show_corkboard: bool, + /// Whether the quick-capture popup is open. + show_quick_capture: bool, + /// The pending note's text, entered in the quick-capture popup. + quick_capture_text: String, + /// When the active writing sprint started, if any. + sprint_start: Option, + /// Whole-manuscript word count when the active sprint started. + sprint_start_words: usize, /// Whether to open an outline file from a folder context menu. /// Contains the workspace-relative path of the outline file to open. open_outline_file: Option, @@ -526,6 +545,13 @@ impl App { show_manuscript_settings: false, show_outline: false, show_synopsis: false, + show_sprint: false, + sprint_start: None, + sprint_start_words: 0, + show_placeholders: false, + show_corkboard: false, + show_quick_capture: false, + quick_capture_text: String::new(), open_outline_file: None, show_diff: false, diff_text: String::new(), @@ -611,6 +637,12 @@ impl eframe::App for App { if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) { self.open_find(false); } + // Ctrl+Shift+N opens the quick-capture popup, for jotting a stray + // idea down without losing your place in the draft. + if ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::N)) + { + self.show_quick_capture = true; + } // Esc closes the find bar. if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { self.show_find = false; @@ -728,6 +760,18 @@ impl eframe::App for App { if self.show_synopsis { self.synopsis_window(ctx); } + if self.show_sprint { + self.sprint_window(ctx); + } + if self.show_placeholders { + self.placeholders_window(ctx); + } + if self.show_corkboard { + self.corkboard_window(ctx); + } + if self.show_quick_capture { + self.quick_capture_window(ctx); + } if self.show_workspace_search { self.workspace_search_window(ctx); diff --git a/src/app/outline.rs b/src/app/outline.rs index faa24e2..8730bf4 100644 --- a/src/app/outline.rs +++ b/src/app/outline.rs @@ -63,12 +63,40 @@ fn insert_beat(level: &mut Vec, path: &[String], beat: &crate::outline } } +/// A manuscript chapter as `(file name, resolved title, synopsis slug)`, the +/// same shape [`App::manuscript_synopsis_rows`] returns. +type ChapterRow = (String, String, Option); + +/// Match an outline beat's name against a chapter's resolved title or its +/// synopsis slug, case-insensitively. Returns the matching chapter's index in +/// `chapters`. Most beats won't line up with a chapter name — that's normal, +/// not an error, and simply means no "open draft" link is offered for them. +fn match_beat_to_chapter(beat_name: &str, chapters: &[ChapterRow]) -> Option { + let needle = beat_name.trim().to_lowercase(); + if needle.is_empty() { + return None; + } + chapters.iter().position(|(_, title, slug)| { + title.trim().to_lowercase() == needle + || slug + .as_deref() + .is_some_and(|s| s.trim().to_lowercase() == needle) + }) +} + /// Render a beat tree recursively. /// /// `salt` is the path taken to get here. Two sections can carry the same name at /// different points in a file — and at different depths — so a heading's id has -/// to come from its position in the tree, not from its text. -fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree], salt: &str) { +/// to come from its position in the tree, not from its text. `open_file` collects +/// a chapter to jump to when a beat's "open draft" link is clicked. +fn render_beat_tree( + ui: &mut egui::Ui, + tree: &[BeatTree], + salt: &str, + chapters: &[ChapterRow], + open_file: &mut Option, +) { for (i, node) in tree.iter().enumerate() { match node { BeatTree::Heading { name, children } => { @@ -79,13 +107,20 @@ fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree], salt: &str) { .id_salt(&salt) .default_open(true) .show(ui, |ui| { - render_beat_tree(ui, children, &salt); + render_beat_tree(ui, children, &salt, chapters, open_file); }); } BeatTree::Beat { name, filled } => { - let mark = if *filled { "[x]" } else { "[ ]" }; - let text = egui::RichText::new(format!(" {mark} {}", name)); - ui.label(if *filled { text } else { text.weak() }); + ui.horizontal_wrapped(|ui| { + let mark = if *filled { "[x]" } else { "[ ]" }; + let text = egui::RichText::new(format!(" {mark} {}", name)); + ui.label(if *filled { text } else { text.weak() }); + if let Some(idx) = match_beat_to_chapter(name, chapters) { + if ui.link(egui::RichText::new("→ open draft").small()).clicked() { + *open_file = Some(chapters[idx].0.clone()); + } + } + }); } } } @@ -157,6 +192,7 @@ impl App { let mut close = false; let mut open_file: Option = None; let files = self.read_outline(); + let chapters = self.manuscript_synopsis_rows(); egui::Window::new("Outline") .open(&mut open) @@ -223,7 +259,7 @@ impl App { if ui.link("open this file").clicked() { open_file = Some(file.rel.clone()); } - render_beat_tree(ui, &tree, &file.rel); + render_beat_tree(ui, &tree, &file.rel, &chapters, &mut open_file); }); } }); @@ -367,6 +403,49 @@ mod tests { assert!(build_beat_tree(&[]).is_empty()); } + fn chapters() -> Vec { + vec![ + ( + "01-Arrival.md".to_string(), + "She Arrives".to_string(), + Some("A storm strands her in port.".to_string()), + ), + ( + "02-Knock.md".to_string(), + "02 — Knock".to_string(), + None, + ), + ] + } + + #[test] + fn a_beat_matches_a_chapter_by_exact_title() { + assert_eq!(match_beat_to_chapter("She Arrives", &chapters()), Some(0)); + } + + #[test] + fn a_beat_match_is_case_insensitive() { + assert_eq!(match_beat_to_chapter("she arrives", &chapters()), Some(0)); + } + + #[test] + fn a_beat_matches_a_chapter_by_its_synopsis_slug() { + assert_eq!( + match_beat_to_chapter("A storm strands her in port.", &chapters()), + Some(0) + ); + } + + #[test] + fn a_beat_with_no_matching_chapter_finds_nothing() { + assert_eq!(match_beat_to_chapter("Midpoint", &chapters()), None); + } + + #[test] + fn an_empty_beat_name_finds_nothing() { + assert_eq!(match_beat_to_chapter(" ", &chapters()), None); + } + #[test] fn a_real_outline_file_groups_under_its_headings() { // End to end over the two modules, in the shape the snowflake template diff --git a/src/app/placeholders.rs b/src/app/placeholders.rs new file mode 100644 index 0000000..aa08b7a --- /dev/null +++ b/src/app/placeholders.rs @@ -0,0 +1,84 @@ +//! The Placeholders window: every `[TODO]`/`[TK]`/`[???]`-style marker left +//! in the manuscript, so a fast first-draft pass doesn't lose track of the +//! gaps it left behind. + +use super::*; +use crate::placeholder_scan::find_placeholders; + +impl App { + /// The Placeholders window: one row per marker found, grouped by file in + /// manuscript order. Clicking a row jumps straight to it. + pub(super) fn placeholders_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_placeholders; + + // Collect up front (reads the in-memory buffer for the open file, + // disk for the rest), so the window closure only reads. + let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); + let rows: Vec<(String, Vec)> = self + .manuscript_files() + .into_iter() + .filter_map(|(_, name)| { + let raw = if Some(name) == selected_name.as_ref() { + self.document().into_owned() + } else { + std::fs::read_to_string(self.path_for(name)).unwrap_or_default() + }; + let found = find_placeholders(&raw); + if found.is_empty() { + None + } else { + Some((name.clone(), found)) + } + }) + .collect(); + let total: usize = rows.iter().map(|(_, m)| m.len()).sum(); + + let mut jump_to: Option<(String, usize, usize)> = None; + + egui::Window::new("Placeholders") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(480.0) + .default_height(420.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new( + "Bracketed markers like [TODO], [TK], [FIXME] or [???] left in \ + the draft. Click one to jump to it.", + ) + .small() + .weak(), + ); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + if total == 0 { + ui.label("No placeholders found — 🎉"); + return; + } + for (name, found) in &rows { + ui.label(egui::RichText::new(name).strong()); + for marker in found { + if ui + .link(format!(" line {} · [{}]", marker.line, marker.marker)) + .clicked() + { + jump_to = Some((name.clone(), marker.start, marker.end)); + } + } + ui.add_space(4.0); + } + }); + }); + + if let Some((name, start, end)) = jump_to { + if let Some(idx) = self.files.iter().position(|f| *f == name) { + self.select(idx); + self.issue_jump = Some((start, end)); + } + } + self.show_placeholders = open; + } +} diff --git a/src/app/project.rs b/src/app/project.rs index 46351b4..a8f0bab 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -62,10 +62,10 @@ impl App { return; } let template = self.config.effective_project_template(); - - // Check that the template directory exists - if !template.is_dir() { - self.np_status = format!("Template directory {} does not exist.", template.display()); + + if !template.is_available() { + self.np_status = "The configured template folder has no cookiecutter.json in it." + .to_string(); return; } @@ -83,7 +83,7 @@ impl App { let run_hooks = self.config.project_run_hooks; let config = crate::project_template::RenderConfig { - template_dir: template, + template, output_dir: parent, variables: crate::project_template::TemplateVariables::new( &name, @@ -487,6 +487,7 @@ impl App { let mut template = self.config.project_template.display().to_string(); let r = ui.add( egui::TextEdit::singleline(&mut template) + .hint_text("blank = use the bundled template") .desired_width(330.0), ); if r.changed() { @@ -497,20 +498,15 @@ impl App { browse_template = true; } }); - let template_ok = self - .config - .project_template - .join("cookiecutter.json") - .is_file(); - ui.label( - egui::RichText::new(if template_ok { - "✔ cookiecutter.json found".to_string() - } else { - "✖ no cookiecutter.json in that folder".to_string() - }) - .small() - .weak(), - ); + let status = if self.config.project_template.as_os_str().is_empty() { + "✔ Using the bundled template".to_string() + } else if self.config.project_template.join("cookiecutter.json").is_file() { + "✔ cookiecutter.json found".to_string() + } else { + "✖ no cookiecutter.json there — falling back to the bundled template" + .to_string() + }; + ui.label(egui::RichText::new(status).small().weak()); ui.add_space(6.0); egui::Grid::new("project_settings_grid") @@ -536,7 +532,9 @@ impl App { ui.end_row(); }); ui.label( - egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed") + egui::RichText::new( + "✔ Template is compiled into this binary — nothing to install separately", + ) .small() .weak(), ); @@ -613,8 +611,8 @@ impl App { if browse_template { let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template"); - if self.config.effective_project_template().is_dir() { - dialog = dialog.set_directory(&self.config.effective_project_template()); + if self.config.project_template.is_dir() { + dialog = dialog.set_directory(&self.config.project_template); } if let Some(path) = dialog.pick_folder() { self.config.project_template = path; diff --git a/src/app/quick_capture.rs b/src/app/quick_capture.rs new file mode 100644 index 0000000..79f1cd4 --- /dev/null +++ b/src/app/quick_capture.rs @@ -0,0 +1,177 @@ +//! A low-friction "jot it down" popup that appends a timestamped note to the +//! project's Scratch Pad, so a stray idea while drafting doesn't force a +//! detour into the Story Bible or Outline mid-sentence. + +use super::*; +use std::io::Write; + +/// Render one captured note as the text appended to `Quick Notes.md`. +pub(super) fn format_capture(text: &str, timestamp: &str) -> String { + format!("\n---\n*{timestamp}*\n\n{}\n", text.trim()) +} + +/// The current UTC time as `YYYY-MM-DD HH:MM UTC`, with no calendar-library +/// dependency — the same days-since-epoch base `word_log` already uses, +/// turned into a civil date via the standard Hinnant algorithm. +fn now_timestamp() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + let days = secs.div_euclid(86_400); + let secs_of_day = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!( + "{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", + secs_of_day / 3600, + (secs_of_day % 3600) / 60 + ) +} + +/// Days-since-epoch to a proleptic Gregorian (year, month, day), per Howard +/// Hinnant's `civil_from_days` (public domain). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +impl App { + /// The project's Scratch Pad folder, looked for the same way + /// [`App::outline_dir`] finds the Outline folder. + fn scratch_pad_dir(&self) -> Option { + const MAX_UP: usize = 3; + let mut dir = Some(self.workspace()); + for _ in 0..=MAX_UP { + let current = dir?; + if let Some(name) = order::child_dir_matching(current, "Scratch Pad") { + return Some(current.join(name)); + } + dir = current.parent(); + } + None + } + + /// Where a captured note is appended: `Quick Notes.md` in the Scratch + /// Pad if one was found, otherwise the workspace root. + fn quick_notes_path(&self) -> PathBuf { + self.scratch_pad_dir() + .unwrap_or_else(|| self.workspace().to_path_buf()) + .join("Quick Notes.md") + } + + /// Append the pending note to `Quick Notes.md` and clear the popup. + fn capture_quick_note(&mut self) { + let text = self.quick_capture_text.trim().to_string(); + if text.is_empty() { + self.show_quick_capture = false; + return; + } + let path = self.quick_notes_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let entry = format_capture(&text, &now_timestamp()); + let result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| file.write_all(entry.as_bytes())); + match result { + Ok(()) => { + self.quick_capture_text.clear(); + self.show_quick_capture = false; + self.status = format!("Captured to {}", path.display()); + } + Err(e) => { + self.status = format!("Could not save quick note: {e}"); + } + } + } + + /// The quick-capture popup: one text box, no modal chrome, so it never + /// interrupts a writing session for longer than typing the note takes. + pub(super) fn quick_capture_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_quick_capture; + let mut close = false; + let mut submit = false; + + egui::Window::new("Quick note") + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(360.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new(format!( + "Appended to {}", + self.quick_notes_path().display() + )) + .small() + .weak(), + ); + ui.add_space(4.0); + let resp = ui.add( + egui::TextEdit::multiline(&mut self.quick_capture_text) + .desired_rows(4) + .desired_width(f32::INFINITY) + .hint_text("What just occurred to you?"), + ); + resp.request_focus(); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Capture").clicked() { + submit = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + + if submit { + self.capture_quick_note(); + } else { + self.show_quick_capture = open && !close; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_capture_wraps_the_note_with_its_timestamp() { + assert_eq!( + format_capture("a stray idea", "2026-09-30 14:05 UTC"), + "\n---\n*2026-09-30 14:05 UTC*\n\na stray idea\n" + ); + } + + #[test] + fn format_capture_trims_the_note_text() { + assert_eq!( + format_capture(" padded \n", "2026-01-01 00:00 UTC"), + "\n---\n*2026-01-01 00:00 UTC*\n\npadded\n" + ); + } + + #[test] + fn civil_from_days_matches_known_dates() { + // 1970-01-01 is day 0 by definition. + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 2000-01-01 is a well-known reference point (10957 days later). + assert_eq!(civil_from_days(10_957), (2000, 1, 1)); + // 2024-02-29, to confirm leap days land correctly. + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } +} diff --git a/src/app/snowflake_progress.rs b/src/app/snowflake_progress.rs new file mode 100644 index 0000000..7ae5796 --- /dev/null +++ b/src/app/snowflake_progress.rs @@ -0,0 +1,132 @@ +//! A heuristic, nudge-only progress strip for the Snowflake-staged project +//! template (`00-Scratch Pad` … `09-Archive`): how much each stage folder +//! actually holds, so the planning → draft handoff is visible at a glance. +//! Tolerant of custom/non-snowflake templates — a missing stage folder is +//! simply left out, never an error. + +use super::*; + +/// The bundled snowflake template's stage folders, in order. A project built +/// from a custom template may not have all — or any — of these; each is +/// only shown if it actually exists. +const STAGES: &[&str] = &[ + "00-Scratch Pad", + "01-Premise", + "02-Synopsis", + "03-Story-Bible", + "04-Outline", + "05-First Draft", + "06-Revisions", + "07-Final", + "08-Covers", + "09-Archive", +]; + +/// Word count under which a stage counts as merely "started" rather than +/// "solid" — past template boilerplate, short of a full stage's worth. +const SOLID_THRESHOLD: usize = 200; + +/// Total prose words under each stage folder that actually exists in +/// `workspace`, in template order. A missing folder is left out entirely. +pub(super) fn stage_word_counts(workspace: &Path) -> Vec<(String, usize)> { + STAGES + .iter() + .filter_map(|stage| { + let dir = workspace.join(stage); + if !dir.is_dir() { + return None; + } + let words: usize = markdown_files_under(&dir) + .iter() + .map(|path| count_words(&std::fs::read_to_string(path).unwrap_or_default())) + .sum(); + Some((stage.to_string(), words)) + }) + .collect() +} + +impl App { + /// A compact "○/◐/● stage name" line per snowflake stage folder found in + /// the workspace. Collapsed by default and draws nothing at all when no + /// stage folder is present, so a custom-template project isn't left with + /// an empty header. + pub(super) fn snowflake_progress_section(&self, ui: &mut egui::Ui) { + let counts = stage_word_counts(self.workspace()); + if counts.is_empty() { + return; + } + egui::CollapsingHeader::new("Snowflake progress") + .default_open(false) + .show(ui, |ui| { + for (stage, words) in &counts { + let icon = if *words == 0 { + "○" + } else if *words < SOLID_THRESHOLD { + "◐" + } else { + "●" + }; + ui.label( + egui::RichText::new(format!("{icon} {stage}")) + .small() + .weak(), + ) + .on_hover_text(format!("{words} words")); + } + }); + ui.separator(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "md-manuscript-snowflake-progress-test-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_missing_stage_folder_is_left_out() { + let ws = temp_dir("missing"); + assert!(stage_word_counts(&ws).is_empty()); + } + + #[test] + fn an_empty_stage_folder_counts_zero_words() { + let ws = temp_dir("empty"); + fs::create_dir_all(ws.join("01-Premise")).unwrap(); + let counts = stage_word_counts(&ws); + assert_eq!(counts, vec![("01-Premise".to_string(), 0)]); + } + + #[test] + fn a_populated_stage_folder_sums_words_across_its_files() { + let ws = temp_dir("populated"); + let stage = ws.join("05-First Draft"); + fs::create_dir_all(stage.join("Act 1")).unwrap(); + fs::write(stage.join("Act 1").join("ch01.md"), "one two three").unwrap(); + fs::write(stage.join("ch02.md"), "four five").unwrap(); + let counts = stage_word_counts(&ws); + assert_eq!(counts, vec![("05-First Draft".to_string(), 5)]); + } + + #[test] + fn stages_are_reported_in_template_order_not_filesystem_order() { + let ws = temp_dir("order"); + fs::create_dir_all(ws.join("09-Archive")).unwrap(); + fs::create_dir_all(ws.join("01-Premise")).unwrap(); + let counts = stage_word_counts(&ws); + assert_eq!( + counts.iter().map(|(s, _)| s.as_str()).collect::>(), + vec!["01-Premise", "09-Archive"] + ); + } +} diff --git a/src/app/sprint.rs b/src/app/sprint.rs new file mode 100644 index 0000000..e589f19 --- /dev/null +++ b/src/app/sprint.rs @@ -0,0 +1,128 @@ +//! A daily whole-manuscript word goal and a Pomodoro-style writing sprint +//! timer, so momentum from planning carries into actually hitting words on +//! the page. The goal is read fresh against `word_log` each frame, so it +//! resets at the day boundary without any extra bookkeeping. + +use super::*; + +impl App { + /// Words added to the manuscript since the calendar day started (UTC), + /// even across closing and reopening the app during the same day. + pub(super) fn words_today(&self) -> i64 { + let baseline = self.word_log.baseline_before(crate::word_log::today()); + self.manuscript_words() as i64 - baseline as i64 + } + + /// Time left in the active sprint, if one is running and hasn't finished. + pub(super) fn sprint_remaining(&self) -> Option { + let start = self.sprint_start?; + let total = Duration::from_secs(self.config.sprint_minutes as u64 * 60); + total.checked_sub(start.elapsed()) + } + + /// The Sprint window: today's progress toward the daily goal, plus + /// start/stop controls for a writing sprint. + pub(super) fn sprint_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_sprint; + let mut close = false; + + let words_today = self.words_today().max(0) as usize; + let goal = self.config.session_word_goal; + let (frac, color, label) = goal_progress( + crate::preprocess::WordGoal { + min: goal, + max: goal, + }, + words_today, + ); + + let sprint_active = self.sprint_start.is_some(); + let remaining = self.sprint_remaining(); + let sprint_words = self + .sprint_start + .map(|_| self.manuscript_words().saturating_sub(self.sprint_start_words)); + + egui::Window::new("Sprint") + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(320.0) + .show(ctx, |ui| { + ui.label(egui::RichText::new("Today").strong()); + ui.add(egui::ProgressBar::new(frac).fill(color).text(label)); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Daily goal:"); + let mut goal_val = self.config.session_word_goal; + if ui + .add(egui::DragValue::new(&mut goal_val).range(50..=20_000).speed(10)) + .changed() + { + self.config.session_word_goal = goal_val; + self.config.save(); + } + ui.label("words"); + }); + + ui.separator(); + ui.label(egui::RichText::new("Sprint").strong()); + ui.horizontal(|ui| { + ui.label("Length:"); + let mut minutes = self.config.sprint_minutes; + if ui + .add(egui::DragValue::new(&mut minutes).range(1..=180).speed(1)) + .changed() + { + self.config.sprint_minutes = minutes; + self.config.save(); + } + ui.label("minutes"); + }); + ui.add_space(4.0); + + if sprint_active { + match remaining { + Some(left) => { + let secs = left.as_secs(); + ui.label( + egui::RichText::new(format!( + "⏱ {:02}:{:02} remaining", + secs / 60, + secs % 60 + )) + .strong(), + ); + // Keep the countdown ticking even with no input. + ctx.request_repaint_after(Duration::from_millis(500)); + } + None => { + ui.label(egui::RichText::new("Sprint finished!").strong()); + } + } + if let Some(words) = sprint_words { + let minutes_elapsed = self + .sprint_start + .map(|s| (s.elapsed().as_secs_f64() / 60.0).max(1.0 / 60.0)) + .unwrap_or(1.0 / 60.0); + ui.label(format!( + "{words} words this sprint · {:.0} words/min", + words as f64 / minutes_elapsed + )); + } + if ui.button("Stop").clicked() { + self.sprint_start = None; + } + } else if ui.button("Start sprint").clicked() { + self.sprint_start = Some(Instant::now()); + self.sprint_start_words = self.manuscript_words(); + } + + ui.add_space(4.0); + if ui.button("Close").clicked() { + close = true; + } + }); + + self.show_sprint = open && !close; + } +} diff --git a/src/app/synopsis.rs b/src/app/synopsis.rs index ec37531..d9b9760 100644 --- a/src/app/synopsis.rs +++ b/src/app/synopsis.rs @@ -6,18 +6,14 @@ use super::*; impl App { - /// The synopsis window: one row per manuscript file, in export order. - pub(super) fn synopsis_window(&mut self, ctx: &egui::Context) { - let mut open = self.show_synopsis; - let mut jump_to: Option = None; - - // Collect everything the window needs up front, so the window's - // closure only reads — no borrow conflicts with the deferred jump. + /// One row per manuscript file, in export order: (file name, resolved + /// chapter title, frontmatter `# Slug:` synopsis if any). Shared by the + /// Synopsis window and the Outline window's beat-to-chapter matching. + pub(super) fn manuscript_synopsis_rows(&self) -> Vec<(String, String, Option)> { let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); - let rows: Vec<(String, String, Option)> = self - .manuscript_files() + self.manuscript_files() .into_iter() .map(|(i, name)| { let raw = if Some(name) == selected_name.as_ref() { @@ -34,7 +30,17 @@ impl App { ); (name.clone(), title, header.slug) }) - .collect(); + .collect() + } + + /// The synopsis window: one row per manuscript file, in export order. + pub(super) fn synopsis_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_synopsis; + let mut jump_to: Option = None; + + // Collect everything the window needs up front, so the window's + // closure only reads — no borrow conflicts with the deferred jump. + let rows = self.manuscript_synopsis_rows(); egui::Window::new("Synopsis") .open(&mut open) diff --git a/src/app/ui.rs b/src/app/ui.rs index de7f83f..24a6db5 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -147,6 +147,27 @@ impl App { ); } + // A sprint countdown, but only in Distraction-free mode — + // outside it, the full detail lives in the Sprint window. + let distraction_free = + !self.config.show_toolbar && !self.config.show_file_panel; + if distraction_free { + if let Some(left) = self.sprint_remaining() { + let secs = left.as_secs(); + ui.separator(); + ui.label( + egui::RichText::new(format!( + "⏱ {:02}:{:02}", + secs / 60, + secs % 60 + )) + .weak(), + ) + .on_hover_text("Time left in the current writing sprint"); + ctx.request_repaint_after(Duration::from_millis(500)); + } + } + // Progress toward the file's Word Count Target, if set. if let Some((goal, prose)) = goal { let (frac, color, text) = goal_progress(goal, prose); @@ -283,6 +304,22 @@ impl App { ui.close_menu(); self.show_synopsis = true; } + if ui.button("⏱ Sprint…").clicked() { + ui.close_menu(); + self.show_sprint = true; + } + if ui.button("🔍 Placeholders…").clicked() { + ui.close_menu(); + self.show_placeholders = true; + } + if ui.button("🗂 Corkboard…").clicked() { + ui.close_menu(); + self.show_corkboard = true; + } + if ui.button("📥 Quick note… Ctrl+Shift+N").clicked() { + ui.close_menu(); + self.show_quick_capture = true; + } ui.separator(); let busy = self.beats_rx.is_some(); if ui diff --git a/src/config.rs b/src/config.rs index ef1ad27..c2da6bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; /// Persistent application configuration (which workspace to open, plus UI prefs). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -85,11 +85,12 @@ pub struct Config { /// and `{{date}}` to today's UTC date. Blank = the built-in default. #[serde(default = "default_new_file_template")] pub new_file_template: String, - /// Directory holding the template used by **File ▸ New project…** — the - /// folder containing `cookiecutter.json`. Rendered by the built-in - /// MiniJinja engine; the manifest keeps the cookiecutter name so existing - /// templates still work. - #[serde(default = "default_project_template")] + /// An external template to use for **File ▸ New project…** instead of + /// the one bundled into the binary — a folder containing `cookiecutter.json`, + /// for a writer who has customized their own copy. Empty (the default) + /// means "use the bundled template"; see + /// [`crate::project_template::TemplateSource`]. + #[serde(default)] pub project_template: PathBuf, /// Which folder of a project holds the book — the manuscript folder. A /// generated project always opens at its root; this names the folder inside @@ -175,6 +176,14 @@ pub struct Config { /// don't want it even then. #[serde(default = "default_typewriter_mode")] pub typewriter_mode: bool, + /// Daily whole-manuscript word target the Sprint window tracks progress + /// against. Resets automatically at the day boundary (it's read fresh + /// against `word_log`, not itself day-scoped). + #[serde(default = "default_session_word_goal")] + pub session_word_goal: usize, + /// Length of a writing sprint, in minutes. + #[serde(default = "default_sprint_minutes")] + pub sprint_minutes: u32, } /// Typewriter mode is on by default, for anyone turning on Distraction-free @@ -183,6 +192,16 @@ pub fn default_typewriter_mode() -> bool { true } +/// Default daily word-count goal: a modest, sustainable session target. +pub fn default_session_word_goal() -> usize { + 500 +} + +/// Default sprint length: the classic Pomodoro interval. +pub fn default_sprint_minutes() -> u32 { + 25 +} + /// Default auto-snapshot threshold: 250 words. pub fn default_auto_snapshot_threshold() -> usize { 250 @@ -195,48 +214,6 @@ pub fn default_show_toolbar() -> bool { true } -/// Where the bundled project template lives, for **File ▸ New project…**. -/// -/// Walks up from the executable and then tries the working directory, which -/// covers `cargo run`, a release build run in place, and an installed binary -/// with the templates beside it. -/// -/// Returns a path even when nothing is found, rather than failing: this is the -/// default for a config field, so it is reached on any machine that has no -/// `config.json` yet — and an installed binary without the templates folder -/// beside it is an ordinary way to arrive here, not a reason to refuse to -/// start. The path that comes back simply has no `cookiecutter.json` in it, -/// which the settings dialog reports ("✖ no cookiecutter.json in that folder") -/// and project generation refuses with a message naming the folder. -pub fn default_project_template() -> PathBuf { - const BUNDLED: &str = "templates/snowflake"; - let exe_dir = std::env::current_exe() - .ok() - .and_then(|exe| exe.parent().map(Path::to_path_buf)); - - if let Some(dir) = exe_dir.as_deref() { - for ancestor in dir.ancestors() { - let candidate = ancestor.join(BUNDLED); - if candidate.join("cookiecutter.json").is_file() { - return candidate; - } - } - } - if let Ok(cwd) = std::env::current_dir() { - let candidate = cwd.join(BUNDLED); - if candidate.join("cookiecutter.json").is_file() { - return candidate; - } - } - - // Nothing found: name where the templates are expected to be, so the - // message the user eventually sees points at a useful folder. - match exe_dir { - Some(dir) => dir.join(BUNDLED), - None => PathBuf::from(BUNDLED), - } -} - /// Default subfolder opened after generating a project: the snowflake /// template's drafting folder (05-First Draft), where the chapter files live. /// Contains merged planning and draft content in a single folder. @@ -363,7 +340,7 @@ impl Default for Config { mistral_model: default_mistral_model(), mistral_base_url: default_mistral_base_url(), new_file_template: default_new_file_template(), - project_template: default_project_template(), + project_template: PathBuf::new(), project_open_subdir: default_project_open_subdir(), project_run_hooks: default_project_run_hooks(), projects_dir: default_projects_dir(), @@ -384,6 +361,8 @@ impl Default for Config { auto_snapshot_enabled: false, auto_snapshot_threshold: default_auto_snapshot_threshold(), typewriter_mode: default_typewriter_mode(), + session_word_goal: default_session_word_goal(), + sprint_minutes: default_sprint_minutes(), } } } @@ -447,15 +426,18 @@ impl Config { } } - /// The project template path to use, falling back to the bundled template - /// if the saved path doesn't exist. - pub fn effective_project_template(&self) -> PathBuf { - // If the saved template path exists and has cookiecutter.json, use it + /// Which project template to render for **File ▸ New project…**: the + /// configured external override if it's set and valid, otherwise the + /// template bundled into the binary. A saved override pointing at a + /// folder that no longer has `cookiecutter.json` in it (moved, deleted, + /// or — as happened once — never installed) falls back to the bundled + /// template rather than failing project generation outright. + pub fn effective_project_template(&self) -> crate::project_template::TemplateSource { if self.project_template.join("cookiecutter.json").is_file() { - return self.project_template.clone(); + crate::project_template::TemplateSource::External(self.project_template.clone()) + } else { + crate::project_template::TemplateSource::Bundled } - // Otherwise fall back to the bundled template - default_project_template() } pub fn load() -> Config { @@ -574,7 +556,8 @@ mod tests { assert_eq!(cfg.mistral_api_key, "secret"); // Every new field arrives at its default rather than blank. assert_eq!(cfg.project_open_subdir, default_project_open_subdir()); - assert_eq!(cfg.project_template, default_project_template()); + // Empty means "use the bundled template" — see effective_project_template. + assert_eq!(cfg.project_template, PathBuf::new()); assert_eq!(cfg.projects_dir, default_projects_dir()); assert!(cfg.project_run_hooks, "hooks default to running"); // Credentials are absent until set; blank is what tells a hook to skip. @@ -627,6 +610,17 @@ mod tests { assert!(cfg.typewriter_mode); } + #[test] + fn config_without_the_sprint_fields_still_loads() { + let old = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt" + }"####; + let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); + assert_eq!(cfg.session_word_goal, 500); + assert_eq!(cfg.sprint_minutes, 25); + } + /// An explicit opt-out must survive a reload, the same way `gitea_use_ssh` /// being turned off has to (both default to `true`). #[test] @@ -653,33 +647,33 @@ mod tests { assert_eq!(cfg.project_open_subdir, "05-Plot"); } + /// The old exe-path/cwd filesystem search for the bundled template is + /// gone — it's compiled into the binary now (see + /// `project_template::BUNDLED_SNOWFLAKE`, and + /// `bundled_template_contains_the_expected_files` in that module's + /// tests). What's left to check here is that an *unset* config correctly + /// resolves to the bundled source rather than an external one. #[test] - fn bundled_template_is_found_via_exe_path() { - let path = default_project_template(); - // The function should find the bundled template - // Verify it returns a valid path that contains cookiecutter.json - assert!(path.join("cookiecutter.json").is_file(), - "Template path {} should contain cookiecutter.json", path.display()); - - // Also verify it's using the bundled template (contains "templates/snowflake") - let path_str = path.to_string_lossy(); - assert!(path_str.contains("templates/snowflake"), - "Template path {} should be the bundled template", path.display()); + fn an_unset_project_template_resolves_to_the_bundled_source() { + let cfg = Config::default(); + assert!(matches!( + cfg.effective_project_template(), + crate::project_template::TemplateSource::Bundled + )); } + /// A saved override pointing at a folder with no `cookiecutter.json` in + /// it — moved, deleted, or never installed, like the bug that prompted + /// this — must fall back to the bundled template, not error out. #[test] - fn bundled_template_contains_05_first_draft() { - let path = default_project_template(); - let template_dir = path.join("{{ cookiecutter.project_name }}"); - - // Verify the template has the merged 05-First Draft folder - assert!(template_dir.join("05-First Draft").is_dir(), - "Bundled template should contain 05-First Draft directory"); - - // Verify it does NOT have the old separate directories - assert!(!template_dir.join("05-Plot").is_dir(), - "Bundled template should NOT contain old 05-Plot directory"); - assert!(!template_dir.join("06-First Draft").is_dir(), - "Bundled template should NOT contain old 06-First Draft directory"); + fn a_broken_external_override_falls_back_to_bundled() { + let cfg = Config { + project_template: PathBuf::from("/nonexistent/template/path"), + ..Config::default() + }; + assert!(matches!( + cfg.effective_project_template(), + crate::project_template::TemplateSource::Bundled + )); } } diff --git a/src/main.rs b/src/main.rs index 3fe5be8..21a29a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod odt; mod outline; mod order; mod pdf; +mod placeholder_scan; mod preprocess; mod project_template; mod screenplay; diff --git a/src/placeholder_scan.rs b/src/placeholder_scan.rs new file mode 100644 index 0000000..4190342 --- /dev/null +++ b/src/placeholder_scan.rs @@ -0,0 +1,146 @@ +//! Finds bracketed placeholder markers (`[TODO]`, `[TK]`, `[???]`, …) dropped +//! into prose while drafting fast, so nothing gets forgotten before a draft +//! is called done. No `regex` dependency: a hand-rolled bracket scan is +//! plenty for a short, fixed set of markers. + +/// A bracketed placeholder found in a file's text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Placeholder { + /// Byte offset of the `[` in the text. + pub start: usize, + /// Byte offset just past the `]` in the text. + pub end: usize, + /// 1-indexed line number the marker appears on. + pub line: usize, + /// The marker text as written, e.g. `TODO` or `???`. + pub marker: String, +} + +/// Marker keywords recognised inside brackets, matched case-insensitively. +const MARKER_WORDS: &[&str] = &["TODO", "TK", "FIXME", "XXX", "NOTE"]; + +/// Longest a bracketed span can be and still be considered a placeholder +/// rather than, say, a Markdown link's link text. +const MAX_SPAN_LEN: usize = 40; + +/// Find every placeholder marker in `text`. +pub fn find_placeholders(text: &str) -> Vec { + let mut out = Vec::new(); + let mut line = 1; + let mut last_end = 0; + let bytes = text.as_bytes(); + + let mut search_from = 0; + while let Some(rel_start) = text[search_from..].find('[') { + let start = search_from + rel_start; + let Some(rel_close) = text[start + 1..].find(']') else { + break; + }; + let close = start + 1 + rel_close; + let inner = &text[start + 1..close]; + + // Count newlines passed since the last marker (or the start) to + // keep `line` in step without rescanning from the top each time. + line += text[last_end..start].matches('\n').count(); + last_end = start; + + if inner.len() <= MAX_SPAN_LEN && !inner.contains('\n') && is_marker(inner) { + out.push(Placeholder { + start, + end: close + 1, + line, + marker: inner.trim().to_string(), + }); + } + + search_from = close + 1; + if search_from > bytes.len() { + break; + } + } + out +} + +/// Whether bracketed inner text `s` reads as a placeholder: an exact (case +/// -insensitive) marker keyword, or entirely `?` characters. +fn is_marker(s: &str) -> bool { + let trimmed = s.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.chars().all(|c| c == '?') { + return true; + } + MARKER_WORDS + .iter() + .any(|m| m.eq_ignore_ascii_case(trimmed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_each_known_marker_word() { + for word in MARKER_WORDS { + let text = format!("Some prose [{word}] continues."); + let found = find_placeholders(&text); + assert_eq!(found.len(), 1, "expected a match for [{word}]"); + assert_eq!(found[0].marker, *word); + } + } + + #[test] + fn finds_question_mark_placeholders() { + let found = find_placeholders("She said [???] and left."); + assert_eq!(found.len(), 1); + assert_eq!(found[0].marker, "???"); + } + + #[test] + fn marker_matching_is_case_insensitive() { + let found = find_placeholders("[todo] fix this scene"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].marker, "todo"); + } + + #[test] + fn a_markdown_link_is_not_a_false_positive() { + let found = find_placeholders("See [a markdown link](https://example.com) for more."); + assert!(found.is_empty()); + } + + #[test] + fn an_ordinary_bracketed_aside_is_not_a_false_positive() { + let found = find_placeholders("He shrugged [as he always did when cornered]."); + assert!(found.is_empty()); + } + + #[test] + fn line_numbers_are_tracked_across_multiple_lines() { + let text = "Line one.\nLine two has [TK] in it.\nLine three has [FIXME] too."; + let found = find_placeholders(text); + assert_eq!(found.len(), 2); + assert_eq!(found[0].line, 2); + assert_eq!(found[1].line, 3); + } + + #[test] + fn byte_offsets_cover_the_brackets_themselves() { + let text = "abc [TODO] def"; + let found = find_placeholders(text); + assert_eq!(found.len(), 1); + assert_eq!(&text[found[0].start..found[0].end], "[TODO]"); + } + + #[test] + fn an_unterminated_bracket_does_not_hang_or_panic() { + let found = find_placeholders("This has an [unterminated bracket"); + assert!(found.is_empty()); + } + + #[test] + fn no_brackets_at_all_yields_nothing() { + assert!(find_placeholders("Plain prose, nothing to see here.").is_empty()); + } +} diff --git a/src/project_template.rs b/src/project_template.rs index c5f8ebb..e43d82d 100644 --- a/src/project_template.rs +++ b/src/project_template.rs @@ -3,12 +3,81 @@ //! This module replaces the cookiecutter dependency by providing native Rust //! templating for project generation. It uses MiniJinja which is Jinja2-compatible, //! so existing cookiecutter templates (like snowflake) work with minimal changes. +//! +//! The default (bundled) template's files are compiled into the binary via +//! [`BUNDLED_SNOWFLAKE`], so **File ▸ New project…** works out of the box — +//! no `templates/` folder has to be installed beside the executable. A writer +//! can still point at a folder of their own (`Settings ▸ New project… ▸ +//! Template`) to use a customized copy instead; see [`TemplateSource`]. +use include_dir::{include_dir, Dir}; use minijinja::Environment; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::fs; +/// The bundled "snowflake" project template, compiled into the binary so +/// **File ▸ New project…** never depends on a `templates/` folder installed +/// beside the executable. +static BUNDLED_SNOWFLAKE: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/snowflake"); + +/// Where a project template comes from: the one compiled into the binary, or +/// a folder on disk the writer has pointed **Settings ▸ New project… ▸ +/// Template** at (to use a customized copy of their own). +#[derive(Debug, Clone)] +pub enum TemplateSource { + Bundled, + External(PathBuf), +} + +impl TemplateSource { + /// Whether this source is actually usable. `Bundled` always is — it's + /// compiled in; `External` needs a real `cookiecutter.json` at the path. + pub fn is_available(&self) -> bool { + match self { + TemplateSource::Bundled => true, + TemplateSource::External(path) => path.join("cookiecutter.json").is_file(), + } + } +} + +/// Removes its directory on drop, so the bundled template's temp extraction +/// is cleaned up whether `render_template` returns `Ok` or `Err`. +struct TempExtraction { + dir: PathBuf, +} + +impl Drop for TempExtraction { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} + +/// Write the bundled template out to a fresh temp directory, so the rest of +/// the render pipeline can treat it exactly like an on-disk template. +fn extract_bundled() -> Result<(PathBuf, TempExtraction), String> { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!( + "md-manuscript-template-{}-{unique}", + std::process::id() + )); + // `Dir::extract` fails if any file already exists, so clear any stale + // leftovers (e.g. from a previous crash) first. + let _ = fs::remove_dir_all(&dir); + // `extract` creates *sub*directories as it walks in, but never the root + // path itself — with top-level files in the template (cookiecutter.json, + // README.md), it would otherwise try to write into a directory that + // doesn't exist yet. + fs::create_dir_all(&dir).map_err(|e| format!("Cannot create {}: {e}", dir.display()))?; + BUNDLED_SNOWFLAKE + .extract(&dir) + .map_err(|e| format!("Failed to extract the bundled template: {e}"))?; + Ok((dir.clone(), TempExtraction { dir })) +} + /// Template variables passed to MiniJinja. #[derive(Debug, Clone)] pub struct TemplateVariables { @@ -55,8 +124,9 @@ impl TemplateVariables { /// Configuration for template rendering. pub struct RenderConfig { - /// Path to the template directory (contains cookiecutter.json and template folder) - pub template_dir: PathBuf, + /// Where the template (with its cookiecutter.json and template folder) + /// comes from. + pub template: TemplateSource, /// Output directory where the project will be created pub output_dir: PathBuf, /// Template variables @@ -77,12 +147,24 @@ pub type RenderResult = Result; /// a template expression (e.g., `{{ cookiecutter.project_name }}`) which /// becomes the project root after rendering. pub fn render_template(config: &RenderConfig) -> RenderResult { + // Resolve the template to a real directory on disk: an external override + // is used as-is, while the bundled template is extracted to a fresh temp + // directory first. `_guard` cleans that temp directory up when this + // function returns, however it returns. + let (template_dir, _guard): (PathBuf, Option) = match &config.template { + TemplateSource::External(path) => (path.clone(), None), + TemplateSource::Bundled => { + let (dir, guard) = extract_bundled()?; + (dir, Some(guard)) + } + }; + // Verify the template directory has a cookiecutter.json - let cookiecutter_json = config.template_dir.join("cookiecutter.json"); + let cookiecutter_json = template_dir.join("cookiecutter.json"); if !cookiecutter_json.is_file() { return Err(format!( "No cookiecutter.json in {} — that folder is not a template.", - config.template_dir.display() + template_dir.display() )); } @@ -130,8 +212,8 @@ pub fn render_template(config: &RenderConfig) -> RenderResult { // Find the template project directory (the one with the templated name) // This is typically named something like "{{ cookiecutter.project_name }}" - let template_entries = fs::read_dir(&config.template_dir) - .map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?; + let template_entries = fs::read_dir(&template_dir) + .map_err(|e| format!("Cannot read template directory {}: {}", template_dir.display(), e))?; let mut template_project_dir: Option = None; for entry in template_entries { @@ -149,7 +231,7 @@ pub fn render_template(config: &RenderConfig) -> RenderResult { let template_project_dir = template_project_dir.ok_or(format!( "No template project directory found in {}. Expected a directory with a templated name like '{{{{ cookiecutter.project_name }}}}'.", - config.template_dir.display() + template_dir.display() ))?; // Render the project directory name @@ -307,22 +389,19 @@ mod tests { assert_eq!(name, "test project"); } - /// Integration test: render the snowflake template using MiniJinja. - /// This tests that the templating works end-to-end with a real template. + /// Integration test: render the bundled snowflake template using + /// MiniJinja. This tests that the templating works end-to-end, and that + /// the compiled-in template is actually extracted and used correctly — + /// there's no "skip if the template isn't installed" escape hatch + /// anymore, since it's always available now. #[test] fn test_render_snowflake_template() { - let template_dir = crate::config::default_project_template(); - if !template_dir.join("cookiecutter.json").is_file() { - eprintln!("skipping: no snowflake template at {}", template_dir.display()); - return; - } - let out = std::env::temp_dir().join("md_manuscript_minijinja_test"); let _ = std::fs::remove_dir_all(&out); std::fs::create_dir_all(&out).unwrap(); let config = RenderConfig { - template_dir, + template: TemplateSource::Bundled, output_dir: out.clone(), variables: TemplateVariables::new( "MiniJinja Test Project", @@ -369,4 +448,37 @@ mod tests { // Clean up let _ = std::fs::remove_dir_all(&out); } + + /// The embed itself has to actually hold the template — a hermetic check + /// (no filesystem, no temp directory) that what's compiled into the + /// binary is a real, usable snowflake template. + #[test] + fn bundled_template_contains_the_expected_files() { + assert!( + BUNDLED_SNOWFLAKE.get_file("cookiecutter.json").is_some(), + "bundled template is missing cookiecutter.json" + ); + let project_dir = BUNDLED_SNOWFLAKE + .dirs() + .find(|d| { + d.path() + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("{{")) + }) + .expect("bundled template is missing its `{{ cookiecutter.project_name }}` folder"); + let subdir = crate::config::default_project_open_subdir(); + assert!( + project_dir.get_dir(project_dir.path().join(&subdir)).is_some(), + "bundled template is missing its {subdir} folder" + ); + } + + #[test] + fn extracting_the_bundled_template_cleans_up_after_itself() { + let (dir, guard) = extract_bundled().expect("extraction should succeed"); + assert!(dir.join("cookiecutter.json").is_file()); + drop(guard); + assert!(!dir.exists(), "temp extraction should be removed on drop"); + } } diff --git a/src/word_log.rs b/src/word_log.rs index 4b14cba..fc9595e 100644 --- a/src/word_log.rs +++ b/src/word_log.rs @@ -67,24 +67,27 @@ impl WordLog { } } + /// The manuscript's word-count baseline for `day`: the most recent + /// *earlier* recorded total (whichever day that was, not necessarily + /// `day - 1`), or 0 if there is none yet. Also the "words written today" + /// baseline the Sprint window subtracts from the live count. + pub fn baseline_before(&self, day: i64) -> usize { + self.days + .iter() + .filter(|d| d.day < day) + .map(|d| d.total_words) + .max() + .unwrap_or(0) + } + /// Whether `day` grew the manuscript: its recorded total is higher than - /// the most recent *earlier* recorded total (whichever day that was — - /// not necessarily `day - 1`; a missing day before it defaults to a - /// baseline of 0, so the very first entry ever logged counts as long as - /// it has any words at all). + /// [`Self::baseline_before`] it. fn wrote(&self, day: i64) -> bool { let Some(today_total) = self.days.iter().find(|d| d.day == day).map(|d| d.total_words) else { return false; }; - let prev_total = self - .days - .iter() - .filter(|d| d.day < day) - .map(|d| d.total_words) - .max() - .unwrap_or(0); - today_total > prev_total + today_total > self.baseline_before(day) } /// The current writing streak as of `today`: consecutive calendar days, @@ -164,6 +167,23 @@ mod tests { assert_eq!(log.days[0].total_words, 900); } + #[test] + fn baseline_before_is_zero_with_no_earlier_days() { + let mut log = WordLog::default(); + log.record(10, 500); + assert_eq!(log.baseline_before(10), 0); + } + + #[test] + fn baseline_before_is_the_most_recent_earlier_total() { + let mut log = WordLog::default(); + log.record(10, 500); + log.record(11, 900); + assert_eq!(log.baseline_before(12), 900); + // A gap before `day` still finds the most recent earlier entry. + assert_eq!(log.baseline_before(20), 900); + } + #[test] fn load_of_a_missing_file_is_an_empty_log_not_an_error() { let dir = std::env::temp_dir().join(format!(