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:
@@ -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
|
||||
|
||||
+52
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user