From 7008463ebfbc141901b8ab1eccce6b9b585cd3ec Mon Sep 17 00:00:00 2001 From: landon Date: Sun, 26 Jul 2026 13:49:27 -0500 Subject: [PATCH] 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 --- README.md | 4 +++ src/app.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/README.md b/README.md index 18534c1..343c099 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.rs b/src/app.rs index bb05fe6..7279917 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, } 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 { + 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"); + } +}