Add current-file word counter to the status bar

Shows a live word count for the open file plus the net words added or
removed since this session started. Per-file baselines are snapshotted
when the workspace opens (session_start_counts) and follow renames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
landon
2026-07-26 13:49:27 -05:00
parent 507e0ab724
commit 7008463ebf
2 changed files with 89 additions and 0 deletions
+4
View File
@@ -73,6 +73,10 @@ Terminal=false
document. Each file becomes a chapter headed by its title, optionally followed
by a caption from its `# Slug:` line.
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.
## Header stripping on export
Manuscript files often carry editorial notes and metadata above the prose that
+85
View File
@@ -30,6 +30,9 @@ pub struct App {
show_log: bool,
git_log: String,
is_repo: bool,
/// Each file's word count captured when the workspace was opened, used as the
/// per-file baseline for the "this session" delta.
session_start_counts: HashMap<String, usize>,
}
impl App {
@@ -52,6 +55,7 @@ impl App {
show_log: false,
git_log: String::new(),
is_repo: false,
session_start_counts: HashMap::new(),
};
app.open_workspace();
app
@@ -78,6 +82,7 @@ impl App {
self.buffer.clear();
self.dirty = false;
self.pending_delete = false;
self.session_start_counts = self.snapshot_counts();
if !self.files.is_empty() {
self.select(0);
}
@@ -85,6 +90,17 @@ impl App {
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
}
/// Read every file and return its current on-disk word count.
fn snapshot_counts(&self) -> HashMap<String, usize> {
self.files
.iter()
.map(|name| {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
(name.clone(), count_words(&text))
})
.collect()
}
fn persist_order(&self) {
let _ = order::write_order(self.workspace(), &self.files);
}
@@ -189,6 +205,7 @@ impl App {
Ok(_) => {
self.files.remove(idx);
self.titles.remove(&name);
self.session_start_counts.remove(&name);
self.persist_titles();
self.persist_order();
self.selected = None;
@@ -237,6 +254,9 @@ impl App {
self.titles.insert(new_name.clone(), title);
self.persist_titles();
}
if let Some(words) = self.session_start_counts.remove(&old_name) {
self.session_start_counts.insert(new_name.clone(), words);
}
self.persist_order();
self.status = format!("Renamed to {new_name}");
}
@@ -411,6 +431,28 @@ impl App {
ui.horizontal(|ui| {
let dirty = if self.dirty { " • unsaved" } else { "" };
ui.label(format!("{}{dirty}", self.status));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(idx) = self.selected {
let name = &self.files[idx];
let total = count_words(&self.buffer);
let baseline =
self.session_start_counts.get(name).copied().unwrap_or(0);
let delta = total as i64 - baseline as i64;
let sign = if delta < 0 { "-" } else { "+" };
ui.label(
egui::RichText::new(format!(
"{} words · {sign}{} this session",
thousands(total),
thousands(delta.unsigned_abs() as usize),
))
.weak(),
)
.on_hover_text(
"Words in the current file · net change since this session opened",
);
}
});
});
ui.add_space(2.0);
});
@@ -751,6 +793,25 @@ fn split_title(markdown: &str, filename: &str) -> (String, String) {
(stem, markdown.to_string())
}
/// Count words in a string: runs of non-whitespace separated by whitespace.
fn count_words(s: &str) -> usize {
s.split_whitespace().count()
}
/// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345").
fn thousands(n: usize) -> String {
let digits = n.to_string();
let len = digits.len();
let mut out = String::with_capacity(len + len / 3);
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out
}
/// A dependency-free timestamp for commit messages (UTC seconds since epoch).
fn chrono_like_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -760,3 +821,27 @@ fn chrono_like_timestamp() -> String {
.unwrap_or(0);
format!("@{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_words_across_whitespace() {
assert_eq!(count_words(""), 0);
assert_eq!(count_words(" \n\t "), 0);
assert_eq!(count_words("one"), 1);
assert_eq!(count_words("one two three"), 3);
assert_eq!(count_words(" spread \n over\tlines "), 3);
}
#[test]
fn formats_thousands_separators() {
assert_eq!(thousands(0), "0");
assert_eq!(thousands(42), "42");
assert_eq!(thousands(999), "999");
assert_eq!(thousands(1_000), "1,000");
assert_eq!(thousands(12_345), "12,345");
assert_eq!(thousands(1_234_567), "1,234,567");
}
}