Files
md-manuscript/src/app/snowflake_progress.rs
T
landon 7854a961a9 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>
2026-09-30 11:38:31 -05:00

133 lines
4.5 KiB
Rust

//! 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"]
);
}
}