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:
@@ -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)
|
||||
|
||||
|
||||
+68
-32
@@ -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<String>,
|
||||
/// The `POV:` line, if any.
|
||||
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 {
|
||||
/// 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<String, usize>,
|
||||
/// 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<String, HoverInfo>,
|
||||
/// 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<String, FileMeta>,
|
||||
/// Grammar/spelling issues from the last LanguageTool check.
|
||||
lt_matches: Vec<crate::langtool::Match>,
|
||||
/// 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<String, HoverInfo> {
|
||||
/// 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<String, FileMeta> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user