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", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -1854,6 +1873,7 @@ dependencies = [
"dirs", "dirs",
"eframe", "eframe",
"egui", "egui",
"include_dir",
"minijinja", "minijinja",
"pulldown-cmark", "pulldown-cmark",
"rfd", "rfd",
+4
View File
@@ -24,6 +24,10 @@ ureq = { version = "2", default-features = false, features = ["tls"] }
spellbook = "0.4.2" spellbook = "0.4.2"
# Templating engine for project scaffolding (replaces cookiecutter dependency) # Templating engine for project scaffolding (replaces cookiecutter dependency)
minijinja = { version = "2", features = ["builtins", "loader"] } 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] [profile.release]
opt-level = 2 opt-level = 2
+16 -14
View File
@@ -43,16 +43,13 @@ sudo apt install libgl1 libxkbcommon0 libwayland-client0 libx11-6
```sh ```sh
install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript 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 That's it — the **File ▸ New project…** template is compiled into the binary,
and then up through its parent folders, so a release build run from the source so there's nothing else to install alongside it. If you want to use a
tree finds it without installing anything. Without it the app still starts and customized copy of the template instead of the bundled one, point
everything else works — only **File ▸ New project…** has nothing to scaffold **Settings ▸ New project… ▸ Template** at a folder of your own containing a
from, and says so. You can also point **Settings ▸ New project… ▸ Template** at `cookiecutter.json`; leave it blank to keep using the bundled template.
a copy anywhere else.
Optionally add a desktop launcher at Optionally add a desktop launcher at
`~/.local/share/applications/md-manuscript.desktop`: `~/.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 template and then opens it as the workspace, so you go from nothing to editing
chapters in one step. chapters in one step.
The app now uses **built-in MiniJinja templating** (no external Python The app uses **built-in MiniJinja templating** (no external Python cookiecutter
cookiecutter required) to render templates. A default "snowflake" template is required) to render templates. A default "snowflake" template is compiled
bundled in the `templates/snowflake/` folder of the project, which you can modify into the binary itself — nothing has to be installed alongside it. Its source
by hand to customise the project structure or file contents. 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 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 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. **Settings ▸ New project…** at any folder containing a `cookiecutter.json` file.
The template should contain a subdirectory named with a template expression The template should contain a subdirectory named with a template expression
(e.g., `{{ cookiecutter.project_name }}`) which becomes the project root after (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… ### Settings ▸ New project…
| Setting | Meaning | | 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 | | **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 | | **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 | | **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(); ui.separator();
self.snowflake_progress_section(ui);
// The actions live in their own panel, claimed *before* the // The actions live in their own panel, claimed *before* the
// list: that hands the list whatever height is left over // 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 /// 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). /// 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() { if response.dnd_hover_payload::<usize>().is_none() {
return false; return false;
} }
+44
View File
@@ -20,14 +20,19 @@ mod autocomplete;
mod auto_snapshot; mod auto_snapshot;
mod beats; mod beats;
mod characters; mod characters;
mod corkboard;
mod diff; mod diff;
mod editor; mod editor;
mod file_list; mod file_list;
mod find; mod find;
mod grammar; mod grammar;
mod outline; mod outline;
mod placeholders;
mod project; mod project;
mod quick_capture;
mod snowflake_progress;
mod spelling; mod spelling;
mod sprint;
mod story_bible_editor; mod story_bible_editor;
mod story_bible_ui; mod story_bible_ui;
mod style; mod style;
@@ -347,6 +352,20 @@ pub struct App {
show_outline: bool, show_outline: bool,
/// Whether the Synopsis window is open. /// Whether the Synopsis window is open.
show_synopsis: bool, 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. /// Whether to open an outline file from a folder context menu.
/// Contains the workspace-relative path of the outline file to open. /// Contains the workspace-relative path of the outline file to open.
open_outline_file: Option<String>, open_outline_file: Option<String>,
@@ -526,6 +545,13 @@ impl App {
show_manuscript_settings: false, show_manuscript_settings: false,
show_outline: false, show_outline: false,
show_synopsis: 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, open_outline_file: None,
show_diff: false, show_diff: false,
diff_text: String::new(), 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)) { if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) {
self.open_find(false); 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. // Esc closes the find bar.
if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.show_find = false; self.show_find = false;
@@ -728,6 +760,18 @@ impl eframe::App for App {
if self.show_synopsis { if self.show_synopsis {
self.synopsis_window(ctx); 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 { if self.show_workspace_search {
self.workspace_search_window(ctx); self.workspace_search_window(ctx);
+86 -7
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. /// Render a beat tree recursively.
/// ///
/// `salt` is the path taken to get here. Two sections can carry the same name at /// `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 /// 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. /// to come from its position in the tree, not from its text. `open_file` collects
fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree], salt: &str) { /// 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() { for (i, node) in tree.iter().enumerate() {
match node { match node {
BeatTree::Heading { name, children } => { BeatTree::Heading { name, children } => {
@@ -79,13 +107,20 @@ fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree], salt: &str) {
.id_salt(&salt) .id_salt(&salt)
.default_open(true) .default_open(true)
.show(ui, |ui| { .show(ui, |ui| {
render_beat_tree(ui, children, &salt); render_beat_tree(ui, children, &salt, chapters, open_file);
}); });
} }
BeatTree::Beat { name, filled } => { BeatTree::Beat { name, filled } => {
let mark = if *filled { "[x]" } else { "[ ]" }; ui.horizontal_wrapped(|ui| {
let text = egui::RichText::new(format!(" {mark} {}", name)); let mark = if *filled { "[x]" } else { "[ ]" };
ui.label(if *filled { text } else { text.weak() }); 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 close = false;
let mut open_file: Option<String> = None; let mut open_file: Option<String> = None;
let files = self.read_outline(); let files = self.read_outline();
let chapters = self.manuscript_synopsis_rows();
egui::Window::new("Outline") egui::Window::new("Outline")
.open(&mut open) .open(&mut open)
@@ -223,7 +259,7 @@ impl App {
if ui.link("open this file").clicked() { if ui.link("open this file").clicked() {
open_file = Some(file.rel.clone()); 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()); 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] #[test]
fn a_real_outline_file_groups_under_its_headings() { fn a_real_outline_file_groups_under_its_headings() {
// End to end over the two modules, in the shape the snowflake template // 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;
}
}
+20 -22
View File
@@ -62,10 +62,10 @@ impl App {
return; return;
} }
let template = self.config.effective_project_template(); let template = self.config.effective_project_template();
// Check that the template directory exists if !template.is_available() {
if !template.is_dir() { self.np_status = "The configured template folder has no cookiecutter.json in it."
self.np_status = format!("Template directory {} does not exist.", template.display()); .to_string();
return; return;
} }
@@ -83,7 +83,7 @@ impl App {
let run_hooks = self.config.project_run_hooks; let run_hooks = self.config.project_run_hooks;
let config = crate::project_template::RenderConfig { let config = crate::project_template::RenderConfig {
template_dir: template, template,
output_dir: parent, output_dir: parent,
variables: crate::project_template::TemplateVariables::new( variables: crate::project_template::TemplateVariables::new(
&name, &name,
@@ -487,6 +487,7 @@ impl App {
let mut template = self.config.project_template.display().to_string(); let mut template = self.config.project_template.display().to_string();
let r = ui.add( let r = ui.add(
egui::TextEdit::singleline(&mut template) egui::TextEdit::singleline(&mut template)
.hint_text("blank = use the bundled template")
.desired_width(330.0), .desired_width(330.0),
); );
if r.changed() { if r.changed() {
@@ -497,20 +498,15 @@ impl App {
browse_template = true; browse_template = true;
} }
}); });
let template_ok = self let status = if self.config.project_template.as_os_str().is_empty() {
.config "✔ Using the bundled template".to_string()
.project_template } else if self.config.project_template.join("cookiecutter.json").is_file() {
.join("cookiecutter.json") "✔ cookiecutter.json found".to_string()
.is_file(); } else {
ui.label( "✖ no cookiecutter.json there — falling back to the bundled template"
egui::RichText::new(if template_ok { .to_string()
"✔ cookiecutter.json found".to_string() };
} else { ui.label(egui::RichText::new(status).small().weak());
"✖ no cookiecutter.json in that folder".to_string()
})
.small()
.weak(),
);
ui.add_space(6.0); ui.add_space(6.0);
egui::Grid::new("project_settings_grid") egui::Grid::new("project_settings_grid")
@@ -536,7 +532,9 @@ impl App {
ui.end_row(); ui.end_row();
}); });
ui.label( 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() .small()
.weak(), .weak(),
); );
@@ -613,8 +611,8 @@ impl App {
if browse_template { if browse_template {
let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template"); let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template");
if self.config.effective_project_template().is_dir() { if self.config.project_template.is_dir() {
dialog = dialog.set_directory(&self.config.effective_project_template()); dialog = dialog.set_directory(&self.config.project_template);
} }
if let Some(path) = dialog.pick_folder() { if let Some(path) = dialog.pick_folder() {
self.config.project_template = path; 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::*; use super::*;
impl App { impl App {
/// The synopsis window: one row per manuscript file, in export order. /// One row per manuscript file, in export order: (file name, resolved
pub(super) fn synopsis_window(&mut self, ctx: &egui::Context) { /// chapter title, frontmatter `# Slug:` synopsis if any). Shared by the
let mut open = self.show_synopsis; /// Synopsis window and the Outline window's beat-to-chapter matching.
let mut jump_to: Option<String> = None; pub(super) fn manuscript_synopsis_rows(&self) -> Vec<(String, String, Option<String>)> {
// Collect everything the window needs up front, so the window's
// closure only reads — no borrow conflicts with the deferred jump.
let marker = self.config.draft_marker.clone(); let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width(); let pad_width = self.index_pad_width();
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let rows: Vec<(String, String, Option<String>)> = self self.manuscript_files()
.manuscript_files()
.into_iter() .into_iter()
.map(|(i, name)| { .map(|(i, name)| {
let raw = if Some(name) == selected_name.as_ref() { let raw = if Some(name) == selected_name.as_ref() {
@@ -34,7 +30,17 @@ impl App {
); );
(name.clone(), title, header.slug) (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") egui::Window::new("Synopsis")
.open(&mut open) .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. // Progress toward the file's Word Count Target, if set.
if let Some((goal, prose)) = goal { if let Some((goal, prose)) = goal {
let (frac, color, text) = goal_progress(goal, prose); let (frac, color, text) = goal_progress(goal, prose);
@@ -283,6 +304,22 @@ impl App {
ui.close_menu(); ui.close_menu();
self.show_synopsis = true; 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(); ui.separator();
let busy = self.beats_rx.is_some(); let busy = self.beats_rx.is_some();
if ui if ui
+75 -81
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::PathBuf;
/// Persistent application configuration (which workspace to open, plus UI prefs). /// Persistent application configuration (which workspace to open, plus UI prefs).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -85,11 +85,12 @@ pub struct Config {
/// and `{{date}}` to today's UTC date. Blank = the built-in default. /// and `{{date}}` to today's UTC date. Blank = the built-in default.
#[serde(default = "default_new_file_template")] #[serde(default = "default_new_file_template")]
pub new_file_template: String, pub new_file_template: String,
/// Directory holding the template used by **File ▸ New project…** — the /// An external template to use for **File ▸ New project…** instead of
/// folder containing `cookiecutter.json`. Rendered by the built-in /// the one bundled into the binary — a folder containing `cookiecutter.json`,
/// MiniJinja engine; the manifest keeps the cookiecutter name so existing /// for a writer who has customized their own copy. Empty (the default)
/// templates still work. /// means "use the bundled template"; see
#[serde(default = "default_project_template")] /// [`crate::project_template::TemplateSource`].
#[serde(default)]
pub project_template: PathBuf, pub project_template: PathBuf,
/// Which folder of a project holds the book — the manuscript folder. A /// Which folder of a project holds the book — the manuscript folder. A
/// generated project always opens at its root; this names the folder inside /// 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. /// don't want it even then.
#[serde(default = "default_typewriter_mode")] #[serde(default = "default_typewriter_mode")]
pub typewriter_mode: bool, 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 /// Typewriter mode is on by default, for anyone turning on Distraction-free
@@ -183,6 +192,16 @@ pub fn default_typewriter_mode() -> bool {
true 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. /// Default auto-snapshot threshold: 250 words.
pub fn default_auto_snapshot_threshold() -> usize { pub fn default_auto_snapshot_threshold() -> usize {
250 250
@@ -195,48 +214,6 @@ pub fn default_show_toolbar() -> bool {
true 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 /// Default subfolder opened after generating a project: the snowflake
/// template's drafting folder (05-First Draft), where the chapter files live. /// template's drafting folder (05-First Draft), where the chapter files live.
/// Contains merged planning and draft content in a single folder. /// Contains merged planning and draft content in a single folder.
@@ -363,7 +340,7 @@ impl Default for Config {
mistral_model: default_mistral_model(), mistral_model: default_mistral_model(),
mistral_base_url: default_mistral_base_url(), mistral_base_url: default_mistral_base_url(),
new_file_template: default_new_file_template(), new_file_template: default_new_file_template(),
project_template: default_project_template(), project_template: PathBuf::new(),
project_open_subdir: default_project_open_subdir(), project_open_subdir: default_project_open_subdir(),
project_run_hooks: default_project_run_hooks(), project_run_hooks: default_project_run_hooks(),
projects_dir: default_projects_dir(), projects_dir: default_projects_dir(),
@@ -384,6 +361,8 @@ impl Default for Config {
auto_snapshot_enabled: false, auto_snapshot_enabled: false,
auto_snapshot_threshold: default_auto_snapshot_threshold(), auto_snapshot_threshold: default_auto_snapshot_threshold(),
typewriter_mode: default_typewriter_mode(), 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 /// Which project template to render for **File ▸ New project…**: the
/// if the saved path doesn't exist. /// configured external override if it's set and valid, otherwise the
pub fn effective_project_template(&self) -> PathBuf { /// template bundled into the binary. A saved override pointing at a
// If the saved template path exists and has cookiecutter.json, use it /// 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() { 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 { pub fn load() -> Config {
@@ -574,7 +556,8 @@ mod tests {
assert_eq!(cfg.mistral_api_key, "secret"); assert_eq!(cfg.mistral_api_key, "secret");
// Every new field arrives at its default rather than blank. // Every new field arrives at its default rather than blank.
assert_eq!(cfg.project_open_subdir, default_project_open_subdir()); 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_eq!(cfg.projects_dir, default_projects_dir());
assert!(cfg.project_run_hooks, "hooks default to running"); assert!(cfg.project_run_hooks, "hooks default to running");
// Credentials are absent until set; blank is what tells a hook to skip. // Credentials are absent until set; blank is what tells a hook to skip.
@@ -627,6 +610,17 @@ mod tests {
assert!(cfg.typewriter_mode); 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` /// An explicit opt-out must survive a reload, the same way `gitea_use_ssh`
/// being turned off has to (both default to `true`). /// being turned off has to (both default to `true`).
#[test] #[test]
@@ -653,33 +647,33 @@ mod tests {
assert_eq!(cfg.project_open_subdir, "05-Plot"); 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] #[test]
fn bundled_template_is_found_via_exe_path() { fn an_unset_project_template_resolves_to_the_bundled_source() {
let path = default_project_template(); let cfg = Config::default();
// The function should find the bundled template assert!(matches!(
// Verify it returns a valid path that contains cookiecutter.json cfg.effective_project_template(),
assert!(path.join("cookiecutter.json").is_file(), crate::project_template::TemplateSource::Bundled
"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());
} }
/// 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] #[test]
fn bundled_template_contains_05_first_draft() { fn a_broken_external_override_falls_back_to_bundled() {
let path = default_project_template(); let cfg = Config {
let template_dir = path.join("{{ cookiecutter.project_name }}"); project_template: PathBuf::from("/nonexistent/template/path"),
..Config::default()
// Verify the template has the merged 05-First Draft folder };
assert!(template_dir.join("05-First Draft").is_dir(), assert!(matches!(
"Bundled template should contain 05-First Draft directory"); cfg.effective_project_template(),
crate::project_template::TemplateSource::Bundled
// 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");
} }
} }
+1
View File
@@ -15,6 +15,7 @@ mod odt;
mod outline; mod outline;
mod order; mod order;
mod pdf; mod pdf;
mod placeholder_scan;
mod preprocess; mod preprocess;
mod project_template; mod project_template;
mod screenplay; 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 //! This module replaces the cookiecutter dependency by providing native Rust
//! templating for project generation. It uses MiniJinja which is Jinja2-compatible, //! templating for project generation. It uses MiniJinja which is Jinja2-compatible,
//! so existing cookiecutter templates (like snowflake) work with minimal changes. //! 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 minijinja::Environment;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::fs; 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. /// Template variables passed to MiniJinja.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TemplateVariables { pub struct TemplateVariables {
@@ -55,8 +124,9 @@ impl TemplateVariables {
/// Configuration for template rendering. /// Configuration for template rendering.
pub struct RenderConfig { pub struct RenderConfig {
/// Path to the template directory (contains cookiecutter.json and template folder) /// Where the template (with its cookiecutter.json and template folder)
pub template_dir: PathBuf, /// comes from.
pub template: TemplateSource,
/// Output directory where the project will be created /// Output directory where the project will be created
pub output_dir: PathBuf, pub output_dir: PathBuf,
/// Template variables /// Template variables
@@ -77,12 +147,24 @@ pub type RenderResult = Result<PathBuf, String>;
/// a template expression (e.g., `{{ cookiecutter.project_name }}`) which /// a template expression (e.g., `{{ cookiecutter.project_name }}`) which
/// becomes the project root after rendering. /// becomes the project root after rendering.
pub fn render_template(config: &RenderConfig) -> RenderResult { 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 // 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() { if !cookiecutter_json.is_file() {
return Err(format!( return Err(format!(
"No cookiecutter.json in {} — that folder is not a template.", "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) // Find the template project directory (the one with the templated name)
// This is typically named something like "{{ cookiecutter.project_name }}" // This is typically named something like "{{ cookiecutter.project_name }}"
let template_entries = fs::read_dir(&config.template_dir) let template_entries = fs::read_dir(&template_dir)
.map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?; .map_err(|e| format!("Cannot read template directory {}: {}", template_dir.display(), e))?;
let mut template_project_dir: Option<PathBuf> = None; let mut template_project_dir: Option<PathBuf> = None;
for entry in template_entries { 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!( 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 }}}}'.", "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 // Render the project directory name
@@ -307,22 +389,19 @@ mod tests {
assert_eq!(name, "test project"); assert_eq!(name, "test project");
} }
/// Integration test: render the snowflake template using MiniJinja. /// Integration test: render the bundled snowflake template using
/// This tests that the templating works end-to-end with a real template. /// 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] #[test]
fn test_render_snowflake_template() { 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 out = std::env::temp_dir().join("md_manuscript_minijinja_test");
let _ = std::fs::remove_dir_all(&out); let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap(); std::fs::create_dir_all(&out).unwrap();
let config = RenderConfig { let config = RenderConfig {
template_dir, template: TemplateSource::Bundled,
output_dir: out.clone(), output_dir: out.clone(),
variables: TemplateVariables::new( variables: TemplateVariables::new(
"MiniJinja Test Project", "MiniJinja Test Project",
@@ -369,4 +448,37 @@ mod tests {
// Clean up // Clean up
let _ = std::fs::remove_dir_all(&out); 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 /// Whether `day` grew the manuscript: its recorded total is higher than
/// the most recent *earlier* recorded total (whichever day that was — /// [`Self::baseline_before`] it.
/// 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).
fn wrote(&self, day: i64) -> bool { fn wrote(&self, day: i64) -> bool {
let Some(today_total) = self.days.iter().find(|d| d.day == day).map(|d| d.total_words) let Some(today_total) = self.days.iter().find(|d| d.day == day).map(|d| d.total_words)
else { else {
return false; return false;
}; };
let prev_total = self today_total > self.baseline_before(day)
.days
.iter()
.filter(|d| d.day < day)
.map(|d| d.total_words)
.max()
.unwrap_or(0);
today_total > prev_total
} }
/// The current writing streak as of `today`: consecutive calendar days, /// The current writing streak as of `today`: consecutive calendar days,
@@ -164,6 +167,23 @@ mod tests {
assert_eq!(log.days[0].total_words, 900); 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] #[test]
fn load_of_a_missing_file_is_an_empty_log_not_an_error() { fn load_of_a_missing_file_is_an_empty_log_not_an_error() {
let dir = std::env::temp_dir().join(format!( let dir = std::env::temp_dir().join(format!(