7854a961a9
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>
133 lines
4.5 KiB
Rust
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"]
|
|
);
|
|
}
|
|
}
|