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