Bundle the project template into the binary; add outline-to-draft

links, a sprint timer, placeholder scanning, corkboard, and a
quick-capture inbox

- Compile templates/snowflake into the binary via include_dir, so New
  project… never depends on files installed beside the executable;
  Settings ▸ Template becomes an optional external override, and a
  broken saved override now falls back to the bundled template
  instead of failing project generation.
- Outline beats now link to a matching chapter for a one-click jump.
- Sprint window: a daily whole-manuscript word goal plus a
  Pomodoro-style writing sprint timer, with a compact countdown in the
  status bar while in Distraction-free mode.
- Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???]
  markers left in the draft and jump straight to any of them.
- Snowflake progress strip in the file panel: a nudge-only readout of
  which snowflake stage folders actually have content.
- Corkboard window: drag-reorderable synopsis cards, one per chapter.
- Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into
  the project's Scratch Pad without losing your place in the draft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:38:31 -05:00
parent 6ca8681d08
commit 7854a961a9
19 changed files with 1256 additions and 163 deletions
Generated
+20
View File
@@ -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",
+4
View File
@@ -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
+16 -14
View File
@@ -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 |
+108
View File
@@ -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<usize> = rows
.iter()
.map(|(name, _, _)| self.files.iter().position(|f| f == name).unwrap_or(0))
.collect();
let mut jump_to: Option<String> = 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::<usize>() {
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;
}
}
+2 -1
View File
@@ -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::<usize>().is_none() {
return false;
}
+44
View File
@@ -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<Instant>,
/// 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<String>,
@@ -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);
+83 -4
View File
@@ -63,12 +63,40 @@ fn insert_beat(level: &mut Vec<BeatTree>, 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<String>);
/// 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<usize> {
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<String>,
) {
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 } => {
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<String> = 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<ChapterRow> {
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
+84
View File
@@ -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<crate::placeholder_scan::Placeholder>)> = 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;
}
}
+17 -19
View File
@@ -63,9 +63,9 @@ impl App {
}
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 {
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 in that folder".to_string()
})
.small()
.weak(),
);
"✖ 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;
+177
View File
@@ -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<PathBuf> {
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));
}
}
+132
View File
@@ -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<_>>(),
vec!["01-Premise", "09-Archive"]
);
}
}
+128
View File
@@ -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<Duration> {
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;
}
}
+16 -10
View File
@@ -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<String> = 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<String>)> {
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<String>)> = 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<String> = 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)
+37
View File
@@ -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
+75 -81
View File
@@ -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
));
}
}
+1
View File
@@ -15,6 +15,7 @@ mod odt;
mod outline;
mod order;
mod pdf;
mod placeholder_scan;
mod preprocess;
mod project_template;
mod screenplay;
+146
View File
@@ -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<Placeholder> {
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());
}
}
+128 -16
View File
@@ -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<PathBuf, String>;
/// 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<TempExtraction>) = 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<PathBuf> = 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");
}
}
+32 -12
View File
@@ -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!(