diff --git a/README.md b/README.md index e3b4eee..ec2691d 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ A small desktop application for Debian that treats a directory of markdown files as an ordered manuscript. * **Left pane** — every `*.md` file in the workspace directory, in a manual order - you set by **dragging the `⠿` handle** up and down. **Hover a file** to see its - `# Slug:` line as a tooltip — a handy one-line synopsis of each chapter. + you set by **dragging the `⠿` handle** up and down. **Hover a file** to see a + tooltip built from its header — the `POV:` line and the `# Slug:` synopsis — + a quick at-a-glance summary of each chapter. * **Right pane** — a markdown text editor for the selected file (optional live preview via the *Preview* checkbox). * **Order + contents are saved and version-controlled with git.** The order lives diff --git a/src/app.rs b/src/app.rs index 4200a33..d9d1972 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,6 +28,43 @@ enum IssueSource { None, } +/// Cached header fields shown when hovering a file in the list. +#[derive(Default, Clone)] +struct HoverInfo { + /// The `# Slug:` synopsis line, if any. + slug: Option, + /// The `POV:` line, if any. + pov: Option, +} + +impl HoverInfo { + /// Extract the hover fields from a document's header. + fn from_markdown(text: &str, marker: &str) -> Self { + let h = crate::preprocess::parse(text, marker); + HoverInfo { + slug: h.slug, + pov: h.pov, + } + } + + fn is_empty(&self) -> bool { + self.slug.is_none() && self.pov.is_none() + } + + /// The tooltip text — a `POV:` line then the slug synopsis — or `None` when + /// the file carries neither field. + fn tooltip(&self) -> Option { + let mut lines = Vec::new(); + if let Some(pov) = &self.pov { + lines.push(format!("POV: {pov}")); + } + if let Some(slug) = &self.slug { + lines.push(slug.clone()); + } + (!lines.is_empty()).then(|| lines.join("\n")) + } +} + /// An owned snapshot of one issue for the results panel, decoupled from `self` /// so the panel can render without holding a borrow across its click handling. struct IssueItem { @@ -74,10 +111,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 `# Slug:` value per file (name -> slug) for the file-list hover + /// 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. - slugs: HashMap, + hover_info: HashMap, /// Grammar/spelling issues from the last LanguageTool check. lt_matches: Vec, /// The exact buffer text the current `lt_matches` were computed against; @@ -163,7 +200,7 @@ impl App { repo_root: None, pending_repo: None, session_start_counts: HashMap::new(), - slugs: HashMap::new(), + hover_info: HashMap::new(), lt_matches: Vec::new(), lt_checked_text: String::new(), lt_status: String::new(), @@ -221,7 +258,7 @@ impl App { self.pending_delete = false; self.clear_lt(); self.session_start_counts = self.snapshot_counts(); - self.slugs = self.snapshot_slugs(); + self.hover_info = self.snapshot_hover_info(); if !self.files.is_empty() { self.select(0); } @@ -295,15 +332,15 @@ impl App { .collect() } - /// Read every file and return its `# Slug:` header value (only for files that - /// have one), for the file-list hover tooltip. - fn snapshot_slugs(&self) -> HashMap { + /// 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 { self.files .iter() .filter_map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - let header = crate::preprocess::parse(&text, &self.config.draft_marker); - header.slug.map(|slug| (name.clone(), slug)) + let info = HoverInfo::from_markdown(&text, &self.config.draft_marker); + (!info.is_empty()).then(|| (name.clone(), info)) }) .collect() } @@ -346,19 +383,13 @@ impl App { Ok(_) => { self.dirty = false; self.status = format!("Saved {name}"); - // Keep the hover-tooltip slug cache in step. - let slug = crate::preprocess::parse( - &self.buffer, - &self.config.draft_marker, - ) - .slug; - match slug { - Some(s) => { - self.slugs.insert(name, s); - } - None => { - self.slugs.remove(&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); + } else { + self.hover_info.insert(name, info); } } Err(e) => self.status = format!("Save failed: {e}"), @@ -445,7 +476,7 @@ impl App { self.files.remove(idx); self.titles.remove(&name); self.session_start_counts.remove(&name); - self.slugs.remove(&name); + self.hover_info.remove(&name); self.persist_titles(); self.persist_order(); self.selected = None; @@ -497,8 +528,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(slug) = self.slugs.remove(&old_name) { - self.slugs.insert(new_name.clone(), slug); + if let Some(info) = self.hover_info.remove(&old_name) { + self.hover_info.insert(new_name.clone(), info); } self.persist_order(); self.status = format!("Renamed to {new_name}"); @@ -1325,14 +1356,14 @@ impl App { for idx in 0..self.files.len() { let name = self.files[idx].clone(); let selected = self.selected == Some(idx); - // The selected file's slug is read live from the + // 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 slug = if selected { - crate::preprocess::parse(&self.buffer, &self.config.draft_marker) - .slug + let tooltip = if selected { + HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker) + .tooltip() } else { - self.slugs.get(&name).cloned() + self.hover_info.get(&name).and_then(HoverInfo::tooltip) }; let row = ui .horizontal(|ui| { @@ -1349,8 +1380,8 @@ impl App { [ui.available_width(), 20.0], egui::SelectableLabel::new(selected, &name), ); - if let Some(slug) = &slug { - label = label.on_hover_text(slug); + if let Some(tooltip) = &tooltip { + label = label.on_hover_text(tooltip); } if label.clicked() { clicked = Some(idx); diff --git a/src/preprocess.rs b/src/preprocess.rs index 6cb2db3..e6a59ea 100644 --- a/src/preprocess.rs +++ b/src/preprocess.rs @@ -29,6 +29,8 @@ pub struct Header { pub title: Option, /// Value of the `# Slug:` line, if present and non-empty. pub slug: Option, + /// Value of the `POV:` line, if present and non-empty. + pub pov: Option, /// Word-count target from a `Word Count Target:` line, if present and parsable. pub goal: Option, /// The prose body, with comments, the header block, and metadata lines removed. @@ -39,6 +41,7 @@ pub struct Header { enum Meta { Title, Slug, + Pov, WordCount, } @@ -105,6 +108,7 @@ fn meta_line(line: &str) -> Option<(Meta, String)> { for (key, meta) in [ ("title:", Meta::Title), ("slug:", Meta::Slug), + ("pov:", Meta::Pov), ("word count target:", Meta::WordCount), ("word count goal:", Meta::WordCount), ("word count:", Meta::WordCount), @@ -134,10 +138,12 @@ pub fn parse(markdown: &str, marker: &str) -> Header { let mut title = None; let mut slug = None; + let mut pov = None; let mut goal = None; let mut set = |meta: Meta, value: String| match meta { Meta::Title => title = Some(value), Meta::Slug => slug = Some(value), + Meta::Pov => pov = Some(value), Meta::WordCount => goal = parse_goal(&value), }; @@ -173,6 +179,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header { Header { title: title.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()), + pov: pov.filter(|s| !s.is_empty()), goal, body: body_lines.join("\n").trim().to_string(), } @@ -265,4 +272,15 @@ mod tests { let md = "# Title: Plain\n### Rough Draft:\n\nText."; assert_eq!(parse(md, MARKER).goal, None); } + + #[test] + fn parses_pov_at_heading_level_two() { + let md = "# Title: Bixby\n## POV: Bixby\n### Rough Draft:\n\nThe prose."; + let h = parse(md, MARKER); + assert_eq!(h.pov.as_deref(), Some("Bixby")); + assert_eq!(h.body, "The prose."); + // A prose line that merely starts with "POV" is not metadata. + let md2 = "### Rough Draft:\n\nPOV of the crowd was tense."; + assert_eq!(parse(md2, MARKER).pov, None); + } }