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();