diff --git a/README.md b/README.md index 7bc1e83..8f2b77b 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,22 @@ The status bar shows a live **word count** for the current file, plus the net words added (or removed) to it since the current session started. The count updates as you type, including unsaved edits. +### Per-file word-count targets + +Give a file a target by adding a **`Word Count Target:`** line to its header (at +any heading level): + +```markdown +## Word Count Target: 1500 - 2000 +``` + +A **progress bar** then appears in the status bar, comparing the file's **prose** +word count (the body below the draft marker, so header metadata isn't counted) +against the target: **amber** while under the target, **green** once inside the +range, **blue** when over it. A single number (`## Word Count Target: 1800`) sets +a point goal; ranges accept `-`, `–`, `to`, and grouped digits (`1,500`). Like +the other header lines, the target is stripped from the exported document. + ## Spelling (offline) Spelling is checked **live as you type**, entirely offline — no server, no diff --git a/src/app.rs b/src/app.rs index 3763bd8..9e98d71 100644 --- a/src/app.rs +++ b/src/app.rs @@ -523,6 +523,16 @@ impl App { resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width()) } + /// The current file's word-count target (from its `Word Count Target:` + /// header) paired with its current prose word count, if a target is set. + /// Prose = the body below the draft marker, so header metadata isn't counted. + fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { + self.selected?; + let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker); + let goal = header.goal?; + Some((goal, count_words(&header.body))) + } + fn export_odt(&mut self) { self.save_current(); let marker = self.config.draft_marker.clone(); @@ -1205,6 +1215,7 @@ impl App { let dirty = if self.dirty { " • unsaved" } else { "" }; ui.label(format!("{}{dirty}", self.status)); + let goal = self.current_goal(); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if let Some(idx) = self.selected { let name = &self.files[idx]; @@ -1224,6 +1235,23 @@ impl App { .on_hover_text( "Words in the current file · net change since this session opened", ); + + // Progress toward the file's Word Count Target, if set. + if let Some((goal, prose)) = goal { + let (frac, color, text) = goal_progress(goal, prose); + ui.separator(); + ui.add( + egui::ProgressBar::new(frac) + .desired_width(150.0) + .fill(color) + .text(egui::RichText::new(text).small()), + ) + .on_hover_text( + "Prose words vs the file's “Word Count Target” header \ + (counts the body below the draft marker). Amber = under \ + target, green = in range, blue = over.", + ); + } } }); }); @@ -2564,6 +2592,30 @@ fn count_words(s: &str) -> usize { s.split_whitespace().count() } +/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour +/// (amber under the range, green within it, blue over it), and a label like +/// "1,234 / 1,500–2,000". +fn goal_progress(goal: crate::preprocess::WordGoal, prose: usize) -> (f32, egui::Color32, String) { + let frac = if goal.max == 0 { + 0.0 + } else { + (prose as f32 / goal.max as f32).clamp(0.0, 1.0) + }; + let color = if prose < goal.min { + egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target + } else if prose <= goal.max { + egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range + } else { + egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target + }; + let target = if goal.min == goal.max { + thousands(goal.max) + } else { + format!("{}–{}", thousands(goal.min), thousands(goal.max)) + }; + (frac, color, format!("{} / {}", thousands(prose), target)) +} + /// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345"). fn thousands(n: usize) -> String { let digits = n.to_string(); diff --git a/src/help.rs b/src/help.rs index a28f93e..15b76cd 100644 --- a/src/help.rs +++ b/src/help.rs @@ -115,6 +115,10 @@ fn cheatsheet_body(ui: &mut egui::Ui) { &[ ("# Title: Chapter Name", "Sets this file's chapter heading on export."), ("# Slug: a caption", "A caption shown beneath the chapter title."), + ( + "## Word Count Target: 1500 - 2000", + "Shows a progress bar in the status bar (a single number or a range).", + ), ], ); note( diff --git a/src/preprocess.rs b/src/preprocess.rs index 886a6e3..6cb2db3 100644 --- a/src/preprocess.rs +++ b/src/preprocess.rs @@ -13,12 +13,24 @@ //! If a file has no marker line, nothing is treated as a header block, but any //! stray `# Title:` / `# Slug:` lines are still lifted out and removed. +/// A per-file word-count target parsed from a `Word Count Target:` header line. +/// A single number gives `min == max`; a range like `1500 - 2000` gives both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WordGoal { + /// Lower bound of the target (the point "in range" begins). + pub min: usize, + /// Upper bound of the target (for a single number, equal to `min`). + pub max: usize, +} + /// The parsed result of pre-processing one markdown document. pub struct Header { /// Value of the `# Title:` line, if present and non-empty. pub title: Option, /// Value of the `# Slug:` line, if present and non-empty. pub slug: Option, + /// Word-count target from a `Word Count Target:` line, if present and parsable. + pub goal: Option, /// The prose body, with comments, the header block, and metadata lines removed. pub body: String, } @@ -27,6 +39,30 @@ pub struct Header { enum Meta { Title, Slug, + WordCount, +} + +/// Parse a word-count target value such as `1500 - 2000`, `1500-2000`, +/// `1,500 to 2,000`, or a single `2000`. Any two numbers are taken as the +/// bounds (ordered); a lone number becomes both bounds. Returns `None` if the +/// value contains no digits. +fn parse_goal(value: &str) -> Option { + // Remove digit-grouping characters so "1,500" stays one number, then split + // the remainder on any non-digit (dashes, "to", spaces) into number tokens. + let ungrouped: String = value.chars().filter(|c| *c != ',' && *c != '_').collect(); + let nums: Vec = ungrouped + .split(|c: char| !c.is_ascii_digit()) + .filter(|tok| !tok.is_empty()) + .filter_map(|tok| tok.parse::().ok()) + .collect(); + match nums.as_slice() { + [] => None, + [n] => Some(WordGoal { min: *n, max: *n }), + [a, b, ..] => Some(WordGoal { + min: (*a).min(*b), + max: (*a).max(*b), + }), + } } /// Remove `` comments (matching the non-greedy ``), @@ -49,16 +85,30 @@ pub fn strip_comments(s: &str) -> String { out } -/// If `line` is a `# Title:` / `# Slug:` metadata line, return which one and its -/// value. Matches a single leading `#` followed by whitespace, case-insensitively. +/// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or +/// `## Word Count Target:` — return which one and its value. Matches one or more +/// leading `#` followed by whitespace, case-insensitively, so the field can sit +/// at any heading level (`#`, `##`, …). fn meta_line(line: &str) -> Option<(Meta, String)> { - let after_hash = line.trim_start().strip_prefix('#')?; + let trimmed = line.trim_start(); + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + if hashes == 0 { + return None; + } + let after_hash = &trimmed[hashes..]; if !after_hash.starts_with(char::is_whitespace) { return None; } let rest = after_hash.trim_start(); let lower = rest.to_ascii_lowercase(); - for (key, meta) in [("title:", Meta::Title), ("slug:", Meta::Slug)] { + // Longer keys first so "word count target:" wins over "word count:". + for (key, meta) in [ + ("title:", Meta::Title), + ("slug:", Meta::Slug), + ("word count target:", Meta::WordCount), + ("word count goal:", Meta::WordCount), + ("word count:", Meta::WordCount), + ] { if lower.starts_with(key) { return Some((meta, rest[key.len()..].trim().to_string())); } @@ -84,9 +134,11 @@ pub fn parse(markdown: &str, marker: &str) -> Header { let mut title = None; let mut slug = None; + let mut goal = None; let mut set = |meta: Meta, value: String| match meta { Meta::Title => title = Some(value), Meta::Slug => slug = Some(value), + Meta::WordCount => goal = parse_goal(&value), }; let body_lines: Vec<&str> = match marker_idx { @@ -121,6 +173,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header { Header { title: title.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()), + goal, body: body_lines.join("\n").trim().to_string(), } } @@ -175,4 +228,41 @@ mod tests { let h = parse(md, MARKER); assert!(h.body.contains("# Chapter One")); } + + #[test] + fn parses_word_count_target_range_at_heading_level_two() { + let md = "# Title: Bixby\n## Word Count Target: 1500 - 2000\n\ + ## POV: Bixby\n### Rough Draft:\n\nThe prose."; + let h = parse(md, MARKER); + assert_eq!(h.goal, Some(WordGoal { min: 1500, max: 2000 })); + // The metadata line is lifted out of the body; POV (unrecognised) is + // above the marker so it's dropped too. + assert_eq!(h.body, "The prose."); + } + + #[test] + fn word_count_target_accepts_various_forms() { + assert_eq!(parse_goal("1500 - 2000"), Some(WordGoal { min: 1500, max: 2000 })); + assert_eq!(parse_goal("1500-2000"), Some(WordGoal { min: 1500, max: 2000 })); + assert_eq!(parse_goal("1,500 to 2,000"), Some(WordGoal { min: 1500, max: 2000 })); + assert_eq!(parse_goal("2000 – 1500"), Some(WordGoal { min: 1500, max: 2000 })); + assert_eq!(parse_goal("about 2000"), Some(WordGoal { min: 2000, max: 2000 })); + assert_eq!(parse_goal(" "), None); + assert_eq!(parse_goal("none"), None); + } + + #[test] + fn word_count_goal_alias_and_no_marker() { + // The alias spelling and no draft marker still yield a goal. + let md = "## Word Count Goal: 800\n\nJust prose."; + let h = parse(md, MARKER); + assert_eq!(h.goal, Some(WordGoal { min: 800, max: 800 })); + assert_eq!(h.body, "Just prose."); + } + + #[test] + fn no_goal_when_absent() { + let md = "# Title: Plain\n### Rough Draft:\n\nText."; + assert_eq!(parse(md, MARKER).goal, None); + } }