Add a per-file word-count-target bar in the file list

Each file row now shows a compact progress bar beside its name when the
file sets a Word Count Target, alongside the existing detailed bar in the
status bar for the active file. Same colour scheme (amber under / green in
range / blue over) and a hover tooltip with the exact words / target.

The per-file slug/POV cache is generalised to a FileMeta (slug, POV, goal,
prose word count), filled on open and refreshed on save; the selected
file's row reads live from the buffer so its bar tracks as you type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
landon
2026-08-14 06:55:09 -05:00
parent c926b6696f
commit 894a0746e3
2 changed files with 81 additions and 36 deletions
+13 -4
View File
@@ -121,12 +121,21 @@ any heading level):
## Word Count Target: 1500 - 2000 ## Word Count Target: 1500 - 2000
``` ```
A **progress bar** then appears in the status bar, comparing the file's **prose** A **progress bar** then appears in two places, comparing the file's **prose**
word count (the body below the draft marker, so header metadata isn't counted) 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 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 range, **blue** when over it.
a point goal; ranges accept `-`, ``, `to`, and grouped digits (`1,500`). Like
the other header lines, the target is stripped from the exported document. * A **compact bar beside the file name** in the left pane, so you can scan every
chapter's progress at a glance (hover it for the exact `words / target`). It
updates live as you type in the open file, and reflects the last saved state
for the others.
* A larger, labelled bar in the **status bar** for the file you're currently
editing.
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)
+68 -32
View File
@@ -28,27 +28,35 @@ enum IssueSource {
None, None,
} }
/// Cached header fields shown when hovering a file in the list. /// Cached per-file header info for the file list: the hover tooltip fields plus
/// the word-count target and current prose length (for the per-row progress bar).
#[derive(Default, Clone)] #[derive(Default, Clone)]
struct HoverInfo { struct FileMeta {
/// The `# Slug:` synopsis line, if any. /// The `# Slug:` synopsis line, if any.
slug: Option<String>, slug: Option<String>,
/// The `POV:` line, if any. /// The `POV:` line, if any.
pov: Option<String>, pov: Option<String>,
/// The `Word Count Target:` goal, if any.
goal: Option<crate::preprocess::WordGoal>,
/// Prose (body) word count captured with the rest of this metadata.
prose_words: usize,
} }
impl HoverInfo { impl FileMeta {
/// Extract the hover fields from a document's header. /// Extract the cached fields from a document.
fn from_markdown(text: &str, marker: &str) -> Self { fn from_markdown(text: &str, marker: &str) -> Self {
let h = crate::preprocess::parse(text, marker); let h = crate::preprocess::parse(text, marker);
HoverInfo { FileMeta {
slug: h.slug, slug: h.slug,
pov: h.pov, pov: h.pov,
goal: h.goal,
prose_words: count_words(&h.body),
} }
} }
fn is_empty(&self) -> bool { /// Whether this entry carries anything worth caching/showing.
self.slug.is_none() && self.pov.is_none() fn has_display(&self) -> bool {
self.slug.is_some() || self.pov.is_some() || self.goal.is_some()
} }
/// The tooltip text — a `POV:` line then the slug synopsis — or `None` when /// The tooltip text — a `POV:` line then the slug synopsis — or `None` when
@@ -111,10 +119,10 @@ pub struct App {
/// Each file's word count captured when the workspace was opened, used as the /// Each file's word count captured when the workspace was opened, used as the
/// per-file baseline for the "this session" delta. /// per-file baseline for the "this session" delta.
session_start_counts: HashMap<String, usize>, session_start_counts: HashMap<String, usize>,
/// Cached header fields per file (name -> slug/POV) for the file-list hover /// Cached header info per file (name -> slug/POV/goal/prose) for the file
/// tooltip. Filled when the workspace opens and refreshed on save; the /// list's hover tooltip and per-row progress bar. Filled when the workspace
/// selected file is read live from the buffer instead. /// opens and refreshed on save; the selected file is read live from the buffer.
hover_info: HashMap<String, HoverInfo>, file_meta: HashMap<String, FileMeta>,
/// Grammar/spelling issues from the last LanguageTool check. /// Grammar/spelling issues from the last LanguageTool check.
lt_matches: Vec<crate::langtool::Match>, lt_matches: Vec<crate::langtool::Match>,
/// The exact buffer text the current `lt_matches` were computed against; /// The exact buffer text the current `lt_matches` were computed against;
@@ -200,7 +208,7 @@ impl App {
repo_root: None, repo_root: None,
pending_repo: None, pending_repo: None,
session_start_counts: HashMap::new(), session_start_counts: HashMap::new(),
hover_info: HashMap::new(), file_meta: HashMap::new(),
lt_matches: Vec::new(), lt_matches: Vec::new(),
lt_checked_text: String::new(), lt_checked_text: String::new(),
lt_status: String::new(), lt_status: String::new(),
@@ -258,7 +266,7 @@ impl App {
self.pending_delete = false; self.pending_delete = false;
self.clear_lt(); self.clear_lt();
self.session_start_counts = self.snapshot_counts(); self.session_start_counts = self.snapshot_counts();
self.hover_info = self.snapshot_hover_info(); self.file_meta = self.snapshot_file_meta();
if !self.files.is_empty() { if !self.files.is_empty() {
self.select(0); self.select(0);
} }
@@ -332,15 +340,15 @@ impl App {
.collect() .collect()
} }
/// Read every file and return its hover fields (slug/POV), for files that /// Read every file and return its cached header info (slug/POV/goal/prose),
/// carry at least one, for the file-list tooltip. /// for files that carry anything worth showing in the list.
fn snapshot_hover_info(&self) -> HashMap<String, HoverInfo> { fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> {
self.files self.files
.iter() .iter()
.filter_map(|name| { .filter_map(|name| {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
let info = HoverInfo::from_markdown(&text, &self.config.draft_marker); let meta = FileMeta::from_markdown(&text, &self.config.draft_marker);
(!info.is_empty()).then(|| (name.clone(), info)) meta.has_display().then(|| (name.clone(), meta))
}) })
.collect() .collect()
} }
@@ -383,13 +391,13 @@ impl App {
Ok(_) => { Ok(_) => {
self.dirty = false; self.dirty = false;
self.status = format!("Saved {name}"); self.status = format!("Saved {name}");
// Keep the hover-tooltip cache (slug/POV) in step. // Keep the file-list cache (slug/POV/goal/prose) in step.
let info = let meta =
HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker); FileMeta::from_markdown(&self.buffer, &self.config.draft_marker);
if info.is_empty() { if meta.has_display() {
self.hover_info.remove(&name); self.file_meta.insert(name, meta);
} else { } else {
self.hover_info.insert(name, info); self.file_meta.remove(&name);
} }
} }
Err(e) => self.status = format!("Save failed: {e}"), Err(e) => self.status = format!("Save failed: {e}"),
@@ -476,7 +484,7 @@ impl App {
self.files.remove(idx); self.files.remove(idx);
self.titles.remove(&name); self.titles.remove(&name);
self.session_start_counts.remove(&name); self.session_start_counts.remove(&name);
self.hover_info.remove(&name); self.file_meta.remove(&name);
self.persist_titles(); self.persist_titles();
self.persist_order(); self.persist_order();
self.selected = None; self.selected = None;
@@ -528,8 +536,8 @@ impl App {
if let Some(words) = self.session_start_counts.remove(&old_name) { if let Some(words) = self.session_start_counts.remove(&old_name) {
self.session_start_counts.insert(new_name.clone(), words); self.session_start_counts.insert(new_name.clone(), words);
} }
if let Some(info) = self.hover_info.remove(&old_name) { if let Some(meta) = self.file_meta.remove(&old_name) {
self.hover_info.insert(new_name.clone(), info); self.file_meta.insert(new_name.clone(), meta);
} }
self.persist_order(); self.persist_order();
self.status = format!("Renamed to {new_name}"); self.status = format!("Renamed to {new_name}");
@@ -1359,12 +1367,12 @@ impl App {
// The selected file's fields are read live from the // The selected file's fields are read live from the
// buffer (so unsaved edits show); others come from the // buffer (so unsaved edits show); others come from the
// cache filled on open/save. // cache filled on open/save.
let tooltip = if selected { let meta = if selected {
HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker) FileMeta::from_markdown(&self.buffer, &self.config.draft_marker)
.tooltip()
} else { } else {
self.hover_info.get(&name).and_then(HoverInfo::tooltip) self.file_meta.get(&name).cloned().unwrap_or_default()
}; };
let tooltip = meta.tooltip();
let row = ui let row = ui
.horizontal(|ui| { .horizontal(|ui| {
ui.dnd_drag_source( ui.dnd_drag_source(
@@ -1376,8 +1384,13 @@ impl App {
); );
}, },
); );
// Reserve room on the right for a per-file
// word-count-target bar when the file sets one.
let bar_w = 44.0;
let reserve = if meta.goal.is_some() { bar_w + 6.0 } else { 0.0 };
let label_w = (ui.available_width() - reserve).max(24.0);
let mut label = ui.add_sized( let mut label = ui.add_sized(
[ui.available_width(), 20.0], [label_w, 20.0],
egui::SelectableLabel::new(selected, &name), egui::SelectableLabel::new(selected, &name),
); );
if let Some(tooltip) = &tooltip { if let Some(tooltip) = &tooltip {
@@ -1386,6 +1399,9 @@ impl App {
if label.clicked() { if label.clicked() {
clicked = Some(idx); clicked = Some(idx);
} }
if let Some(goal) = meta.goal {
row_goal_bar(ui, bar_w, goal, meta.prose_words);
}
}) })
.response; .response;
@@ -2694,6 +2710,26 @@ fn goal_progress(goal: crate::preprocess::WordGoal, prose: usize) -> (f32, egui:
(frac, color, format!("{} / {}", thousands(prose), target)) (frac, color, format!("{} / {}", thousands(prose), target))
} }
/// Paint a compact word-count-target progress bar for one file-list row, with
/// the numeric progress (and target) as a hover tooltip. The colour matches the
/// status-bar bar: amber under target, green in range, blue over.
fn row_goal_bar(ui: &mut egui::Ui, width: f32, goal: crate::preprocess::WordGoal, prose: usize) {
let (frac, color, text) = goal_progress(goal, prose);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover());
let rounding = egui::Rounding::same(2.0);
let track = ui.visuals().extreme_bg_color;
let painter = ui.painter();
painter.rect_filled(rect, rounding, track);
if frac > 0.0 {
let fill = egui::Rect::from_min_size(
rect.min,
egui::vec2((rect.width() * frac).max(1.0), rect.height()),
);
painter.rect_filled(fill, rounding, color);
}
resp.on_hover_text(format!("{text} words"));
}
/// 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();