From 894a0746e3bd47585076ef65f6da1b72acd282a2 Mon Sep 17 00:00:00 2001 From: landon Date: Fri, 14 Aug 2026 06:55:09 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs --- README.md | 17 ++++++--- src/app.rs | 100 ++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index ec2691d..5bf4f09 100644 --- a/README.md +++ b/README.md @@ -121,12 +121,21 @@ any heading level): ## 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) 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 -a point goal; ranges accept `-`, `–`, `to`, and grouped digits (`1,500`). Like -the other header lines, the target is stripped from the exported document. +range, **blue** when over it. + +* 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) diff --git a/src/app.rs b/src/app.rs index d9d1972..358baef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,27 +28,35 @@ enum IssueSource { 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)] -struct HoverInfo { +struct FileMeta { /// The `# Slug:` synopsis line, if any. slug: Option, /// The `POV:` line, if any. pov: Option, + /// The `Word Count Target:` goal, if any. + goal: Option, + /// Prose (body) word count captured with the rest of this metadata. + prose_words: usize, } -impl HoverInfo { - /// Extract the hover fields from a document's header. +impl FileMeta { + /// Extract the cached fields from a document. fn from_markdown(text: &str, marker: &str) -> Self { let h = crate::preprocess::parse(text, marker); - HoverInfo { + FileMeta { slug: h.slug, pov: h.pov, + goal: h.goal, + prose_words: count_words(&h.body), } } - fn is_empty(&self) -> bool { - self.slug.is_none() && self.pov.is_none() + /// Whether this entry carries anything worth caching/showing. + 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 @@ -111,10 +119,10 @@ pub struct App { /// 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, - /// Cached header fields per file (name -> slug/POV) for the file-list hover - /// tooltip. Filled when the workspace opens and refreshed on save; the - /// selected file is read live from the buffer instead. - hover_info: HashMap, + /// Cached header info per file (name -> slug/POV/goal/prose) for the file + /// list's hover tooltip and per-row progress bar. Filled when the workspace + /// opens and refreshed on save; the selected file is read live from the buffer. + file_meta: HashMap, /// Grammar/spelling issues from the last LanguageTool check. lt_matches: Vec, /// The exact buffer text the current `lt_matches` were computed against; @@ -200,7 +208,7 @@ impl App { repo_root: None, pending_repo: None, session_start_counts: HashMap::new(), - hover_info: HashMap::new(), + file_meta: HashMap::new(), lt_matches: Vec::new(), lt_checked_text: String::new(), lt_status: String::new(), @@ -258,7 +266,7 @@ impl App { self.pending_delete = false; self.clear_lt(); 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() { self.select(0); } @@ -332,15 +340,15 @@ impl App { .collect() } - /// Read every file and return its hover fields (slug/POV), for files that - /// carry at least one, for the file-list tooltip. - fn snapshot_hover_info(&self) -> HashMap { + /// Read every file and return its cached header info (slug/POV/goal/prose), + /// for files that carry anything worth showing in the list. + fn snapshot_file_meta(&self) -> HashMap { self.files .iter() .filter_map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - let info = HoverInfo::from_markdown(&text, &self.config.draft_marker); - (!info.is_empty()).then(|| (name.clone(), info)) + let meta = FileMeta::from_markdown(&text, &self.config.draft_marker); + meta.has_display().then(|| (name.clone(), meta)) }) .collect() } @@ -383,13 +391,13 @@ impl App { Ok(_) => { self.dirty = false; self.status = format!("Saved {name}"); - // Keep the hover-tooltip cache (slug/POV) in step. - let info = - HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker); - if info.is_empty() { - self.hover_info.remove(&name); + // Keep the file-list cache (slug/POV/goal/prose) in step. + let meta = + FileMeta::from_markdown(&self.buffer, &self.config.draft_marker); + if meta.has_display() { + self.file_meta.insert(name, meta); } else { - self.hover_info.insert(name, info); + self.file_meta.remove(&name); } } Err(e) => self.status = format!("Save failed: {e}"), @@ -476,7 +484,7 @@ impl App { self.files.remove(idx); self.titles.remove(&name); self.session_start_counts.remove(&name); - self.hover_info.remove(&name); + self.file_meta.remove(&name); self.persist_titles(); self.persist_order(); self.selected = None; @@ -528,8 +536,8 @@ impl App { if let Some(words) = self.session_start_counts.remove(&old_name) { self.session_start_counts.insert(new_name.clone(), words); } - if let Some(info) = self.hover_info.remove(&old_name) { - self.hover_info.insert(new_name.clone(), info); + if let Some(meta) = self.file_meta.remove(&old_name) { + self.file_meta.insert(new_name.clone(), meta); } self.persist_order(); self.status = format!("Renamed to {new_name}"); @@ -1359,12 +1367,12 @@ impl App { // The selected file's fields are read live from the // buffer (so unsaved edits show); others come from the // cache filled on open/save. - let tooltip = if selected { - HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker) - .tooltip() + let meta = if selected { + FileMeta::from_markdown(&self.buffer, &self.config.draft_marker) } 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 .horizontal(|ui| { 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( - [ui.available_width(), 20.0], + [label_w, 20.0], egui::SelectableLabel::new(selected, &name), ); if let Some(tooltip) = &tooltip { @@ -1386,6 +1399,9 @@ impl App { if label.clicked() { clicked = Some(idx); } + if let Some(goal) = meta.goal { + row_goal_bar(ui, bar_w, goal, meta.prose_words); + } }) .response; @@ -2694,6 +2710,26 @@ fn goal_progress(goal: crate::preprocess::WordGoal, prose: usize) -> (f32, egui: (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"). fn thousands(n: usize) -> String { let digits = n.to_string();