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
|
document. Each file becomes a chapter headed by its title, optionally followed
|
||||||
by a caption from its `# Slug:` line.
|
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
|
## Header stripping on export
|
||||||
|
|
||||||
Manuscript files often carry editorial notes and metadata above the prose that
|
Manuscript files often carry editorial notes and metadata above the prose that
|
||||||
|
|||||||
+85
@@ -30,6 +30,9 @@ pub struct App {
|
|||||||
show_log: bool,
|
show_log: bool,
|
||||||
git_log: String,
|
git_log: String,
|
||||||
is_repo: bool,
|
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 {
|
impl App {
|
||||||
@@ -52,6 +55,7 @@ impl App {
|
|||||||
show_log: false,
|
show_log: false,
|
||||||
git_log: String::new(),
|
git_log: String::new(),
|
||||||
is_repo: false,
|
is_repo: false,
|
||||||
|
session_start_counts: HashMap::new(),
|
||||||
};
|
};
|
||||||
app.open_workspace();
|
app.open_workspace();
|
||||||
app
|
app
|
||||||
@@ -78,6 +82,7 @@ impl App {
|
|||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
self.pending_delete = false;
|
self.pending_delete = false;
|
||||||
|
self.session_start_counts = self.snapshot_counts();
|
||||||
if !self.files.is_empty() {
|
if !self.files.is_empty() {
|
||||||
self.select(0);
|
self.select(0);
|
||||||
}
|
}
|
||||||
@@ -85,6 +90,17 @@ impl App {
|
|||||||
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
|
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) {
|
fn persist_order(&self) {
|
||||||
let _ = order::write_order(self.workspace(), &self.files);
|
let _ = order::write_order(self.workspace(), &self.files);
|
||||||
}
|
}
|
||||||
@@ -189,6 +205,7 @@ impl App {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.files.remove(idx);
|
self.files.remove(idx);
|
||||||
self.titles.remove(&name);
|
self.titles.remove(&name);
|
||||||
|
self.session_start_counts.remove(&name);
|
||||||
self.persist_titles();
|
self.persist_titles();
|
||||||
self.persist_order();
|
self.persist_order();
|
||||||
self.selected = None;
|
self.selected = None;
|
||||||
@@ -237,6 +254,9 @@ impl App {
|
|||||||
self.titles.insert(new_name.clone(), title);
|
self.titles.insert(new_name.clone(), title);
|
||||||
self.persist_titles();
|
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.persist_order();
|
||||||
self.status = format!("Renamed to {new_name}");
|
self.status = format!("Renamed to {new_name}");
|
||||||
}
|
}
|
||||||
@@ -411,6 +431,28 @@ impl App {
|
|||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
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));
|
||||||
|
|
||||||
|
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);
|
ui.add_space(2.0);
|
||||||
});
|
});
|
||||||
@@ -751,6 +793,25 @@ fn split_title(markdown: &str, filename: &str) -> (String, String) {
|
|||||||
(stem, markdown.to_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).
|
/// A dependency-free timestamp for commit messages (UTC seconds since epoch).
|
||||||
fn chrono_like_timestamp() -> String {
|
fn chrono_like_timestamp() -> String {
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -760,3 +821,27 @@ fn chrono_like_timestamp() -> String {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
format!("@{secs}")
|
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