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
+16
View File
@@ -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 words added (or removed) to it since the current session started. The count
updates as you type, including unsaved edits. 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 (offline)
Spelling is checked **live as you type**, entirely offline — no server, no Spelling is checked **live as you type**, entirely offline — no server, no
+52
View File
@@ -523,6 +523,16 @@ impl App {
resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width()) 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) { fn export_odt(&mut self) {
self.save_current(); self.save_current();
let marker = self.config.draft_marker.clone(); let marker = self.config.draft_marker.clone();
@@ -1205,6 +1215,7 @@ impl App {
let dirty = if self.dirty { " • unsaved" } else { "" }; let dirty = if self.dirty { " • unsaved" } else { "" };
ui.label(format!("{}{dirty}", self.status)); ui.label(format!("{}{dirty}", self.status));
let goal = self.current_goal();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(idx) = self.selected { if let Some(idx) = self.selected {
let name = &self.files[idx]; let name = &self.files[idx];
@@ -1224,6 +1235,23 @@ impl App {
.on_hover_text( .on_hover_text(
"Words in the current file · net change since this session opened", "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() 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"). /// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345").
fn thousands(n: usize) -> String { fn thousands(n: usize) -> String {
let digits = n.to_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."), ("# Title: Chapter Name", "Sets this file's chapter heading on export."),
("# Slug: a caption", "A caption shown beneath the chapter title."), ("# 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( 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 //! 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. //! 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. /// The parsed result of pre-processing one markdown document.
pub struct Header { pub struct Header {
/// Value of the `# Title:` line, if present and non-empty. /// Value of the `# Title:` line, if present and non-empty.
pub title: Option<String>, pub title: Option<String>,
/// Value of the `# Slug:` line, if present and non-empty. /// Value of the `# Slug:` line, if present and non-empty.
pub slug: Option<String>, 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. /// The prose body, with comments, the header block, and metadata lines removed.
pub body: String, pub body: String,
} }
@@ -27,6 +39,30 @@ pub struct Header {
enum Meta { enum Meta {
Title, Title,
Slug, 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]*?-->`), /// Remove `<!-- ... -->` comments (matching the non-greedy `<!--[\s\S]*?-->`),
@@ -49,16 +85,30 @@ pub fn strip_comments(s: &str) -> String {
out out
} }
/// If `line` is a `# Title:` / `# Slug:` metadata line, return which one and its /// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or
/// value. Matches a single leading `#` followed by whitespace, case-insensitively. /// `## 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)> { 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) { if !after_hash.starts_with(char::is_whitespace) {
return None; return None;
} }
let rest = after_hash.trim_start(); let rest = after_hash.trim_start();
let lower = rest.to_ascii_lowercase(); 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) { if lower.starts_with(key) {
return Some((meta, rest[key.len()..].trim().to_string())); 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 title = None;
let mut slug = None; let mut slug = None;
let mut goal = None;
let mut set = |meta: Meta, value: String| match meta { let mut set = |meta: Meta, value: String| match meta {
Meta::Title => title = Some(value), Meta::Title => title = Some(value),
Meta::Slug => slug = Some(value), Meta::Slug => slug = Some(value),
Meta::WordCount => goal = parse_goal(&value),
}; };
let body_lines: Vec<&str> = match marker_idx { let body_lines: Vec<&str> = match marker_idx {
@@ -121,6 +173,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
Header { Header {
title: title.filter(|s| !s.is_empty()), title: title.filter(|s| !s.is_empty()),
slug: slug.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()),
goal,
body: body_lines.join("\n").trim().to_string(), body: body_lines.join("\n").trim().to_string(),
} }
} }
@@ -175,4 +228,41 @@ mod tests {
let h = parse(md, MARKER); let h = parse(md, MARKER);
assert!(h.body.contains("# Chapter One")); 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);
}
} }