Parse per-file Word Count Target header and show progress

Header parsing now recognises a `Word Count Target:` line at any heading
level (## included) in addition to `# Title:`/`# Slug:`, and accepts one
or more leading `#`. The value parses a single number or a range —
"1500 - 2000", "1500-2000", "1,500 to 2,000" — into a min/max WordGoal
(stripped from the exported document like the other header lines).

When a target is set, the status bar shows a progress bar of the file's
prose word count (the body below the draft marker) against it: amber
under the target, green in range, blue over. Aliases "Word Count Goal:"
and "Word Count:" also work.

Adds preprocess tests for the range forms, ## heading level, alias, and
absence; 44 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
landon
2026-08-14 06:39:22 -05:00
parent 19dddf30c7
commit 9d2207ba96
4 changed files with 166 additions and 4 deletions
+52
View File
@@ -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,5002,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();
+4
View File
@@ -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(
+94 -4
View File
@@ -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<String>,
/// Value of the `# Slug:` line, if present and non-empty.
pub slug: Option<String>,
/// Word-count target from a `Word Count Target:` line, if present and parsable.
pub goal: Option<WordGoal>,
/// 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<WordGoal> {
// 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<usize> = ungrouped
.split(|c: char| !c.is_ascii_digit())
.filter(|tok| !tok.is_empty())
.filter_map(|tok| tok.parse::<usize>().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 `<!--[\s\S]*?-->`),
@@ -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);
}
}