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
|
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
@@ -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,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").
|
/// 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();
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user