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:
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+86
-7
@@ -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 } => {
|
||||
let mark = if *filled { "[x]" } else { "[ ]" };
|
||||
let text = egui::RichText::new(format!(" {mark} {}", name));
|
||||
ui.label(if *filled { text } else { text.weak() });
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let mark = if *filled { "[x]" } else { "[ ]" };
|
||||
let text = egui::RichText::new(format!(" {mark} {}", name));
|
||||
ui.label(if *filled { text } else { text.weak() });
|
||||
if let Some(idx) = match_beat_to_chapter(name, chapters) {
|
||||
if ui.link(egui::RichText::new("→ open draft").small()).clicked() {
|
||||
*open_file = Some(chapters[idx].0.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +192,7 @@ impl App {
|
||||
let mut close = false;
|
||||
let mut open_file: Option<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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
-21
@@ -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 {
|
||||
"✔ cookiecutter.json found".to_string()
|
||||
} else {
|
||||
"✖ no cookiecutter.json in that folder".to_string()
|
||||
})
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
let status = if self.config.project_template.as_os_str().is_empty() {
|
||||
"✔ Using the bundled template".to_string()
|
||||
} else if self.config.project_template.join("cookiecutter.json").is_file() {
|
||||
"✔ cookiecutter.json found".to_string()
|
||||
} else {
|
||||
"✖ no cookiecutter.json there — falling back to the bundled template"
|
||||
.to_string()
|
||||
};
|
||||
ui.label(egui::RichText::new(status).small().weak());
|
||||
|
||||
ui.add_space(6.0);
|
||||
egui::Grid::new("project_settings_grid")
|
||||
@@ -536,7 +532,9 @@ impl App {
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed")
|
||||
egui::RichText::new(
|
||||
"✔ Template is compiled into this binary — nothing to install separately",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
@@ -613,8 +611,8 @@ impl App {
|
||||
|
||||
if browse_template {
|
||||
let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template");
|
||||
if self.config.effective_project_template().is_dir() {
|
||||
dialog = dialog.set_directory(&self.config.effective_project_template());
|
||||
if self.config.project_template.is_dir() {
|
||||
dialog = dialog.set_directory(&self.config.project_template);
|
||||
}
|
||||
if let Some(path) = dialog.pick_folder() {
|
||||
self.config.project_template = path;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ mod odt;
|
||||
mod outline;
|
||||
mod order;
|
||||
mod pdf;
|
||||
mod placeholder_scan;
|
||||
mod preprocess;
|
||||
mod project_template;
|
||||
mod screenplay;
|
||||
|
||||
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user