Include the POV field in the file-list hover tooltip
Header parsing now also lifts a `POV:` line (any heading level). The file-list tooltip combines it with the slug — "POV: <name>" then the slug synopsis — cached per file alongside the slug (HoverInfo) and read live from the buffer for the selected file. Adds a POV parse test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
@@ -4,8 +4,9 @@ A small desktop application for Debian that treats a directory of markdown files
|
|||||||
as an ordered manuscript.
|
as an ordered manuscript.
|
||||||
|
|
||||||
* **Left pane** — every `*.md` file in the workspace directory, in a manual order
|
* **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
|
you set by **dragging the `⠿` handle** up and down. **Hover a file** to see a
|
||||||
`# Slug:` line as a tooltip — a handy one-line synopsis of each chapter.
|
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
|
* **Right pane** — a markdown text editor for the selected file (optional live
|
||||||
preview via the *Preview* checkbox).
|
preview via the *Preview* checkbox).
|
||||||
* **Order + contents are saved and version-controlled with git.** The order lives
|
* **Order + contents are saved and version-controlled with git.** The order lives
|
||||||
|
|||||||
+63
-32
@@ -28,6 +28,43 @@ enum IssueSource {
|
|||||||
None,
|
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<String>,
|
||||||
|
/// The `POV:` line, if any.
|
||||||
|
pov: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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`
|
/// 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.
|
/// so the panel can render without holding a borrow across its click handling.
|
||||||
struct IssueItem {
|
struct IssueItem {
|
||||||
@@ -74,10 +111,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 `# 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
|
/// tooltip. Filled when the workspace opens and refreshed on save; the
|
||||||
/// selected file is read live from the buffer instead.
|
/// selected file is read live from the buffer instead.
|
||||||
slugs: HashMap<String, String>,
|
hover_info: HashMap<String, HoverInfo>,
|
||||||
/// 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;
|
||||||
@@ -163,7 +200,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(),
|
||||||
slugs: HashMap::new(),
|
hover_info: 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(),
|
||||||
@@ -221,7 +258,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.slugs = self.snapshot_slugs();
|
self.hover_info = self.snapshot_hover_info();
|
||||||
if !self.files.is_empty() {
|
if !self.files.is_empty() {
|
||||||
self.select(0);
|
self.select(0);
|
||||||
}
|
}
|
||||||
@@ -295,15 +332,15 @@ impl App {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read every file and return its `# Slug:` header value (only for files that
|
/// Read every file and return its hover fields (slug/POV), for files that
|
||||||
/// have one), for the file-list hover tooltip.
|
/// carry at least one, for the file-list tooltip.
|
||||||
fn snapshot_slugs(&self) -> HashMap<String, String> {
|
fn snapshot_hover_info(&self) -> HashMap<String, HoverInfo> {
|
||||||
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 header = crate::preprocess::parse(&text, &self.config.draft_marker);
|
let info = HoverInfo::from_markdown(&text, &self.config.draft_marker);
|
||||||
header.slug.map(|slug| (name.clone(), slug))
|
(!info.is_empty()).then(|| (name.clone(), info))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -346,19 +383,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 slug cache in step.
|
// Keep the hover-tooltip cache (slug/POV) in step.
|
||||||
let slug = crate::preprocess::parse(
|
let info =
|
||||||
&self.buffer,
|
HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker);
|
||||||
&self.config.draft_marker,
|
if info.is_empty() {
|
||||||
)
|
self.hover_info.remove(&name);
|
||||||
.slug;
|
} else {
|
||||||
match slug {
|
self.hover_info.insert(name, info);
|
||||||
Some(s) => {
|
|
||||||
self.slugs.insert(name, s);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.slugs.remove(&name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => self.status = format!("Save failed: {e}"),
|
Err(e) => self.status = format!("Save failed: {e}"),
|
||||||
@@ -445,7 +476,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.slugs.remove(&name);
|
self.hover_info.remove(&name);
|
||||||
self.persist_titles();
|
self.persist_titles();
|
||||||
self.persist_order();
|
self.persist_order();
|
||||||
self.selected = None;
|
self.selected = None;
|
||||||
@@ -497,8 +528,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(slug) = self.slugs.remove(&old_name) {
|
if let Some(info) = self.hover_info.remove(&old_name) {
|
||||||
self.slugs.insert(new_name.clone(), slug);
|
self.hover_info.insert(new_name.clone(), info);
|
||||||
}
|
}
|
||||||
self.persist_order();
|
self.persist_order();
|
||||||
self.status = format!("Renamed to {new_name}");
|
self.status = format!("Renamed to {new_name}");
|
||||||
@@ -1325,14 +1356,14 @@ impl App {
|
|||||||
for idx in 0..self.files.len() {
|
for idx in 0..self.files.len() {
|
||||||
let name = self.files[idx].clone();
|
let name = self.files[idx].clone();
|
||||||
let selected = self.selected == Some(idx);
|
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
|
// buffer (so unsaved edits show); others come from the
|
||||||
// cache filled on open/save.
|
// cache filled on open/save.
|
||||||
let slug = if selected {
|
let tooltip = if selected {
|
||||||
crate::preprocess::parse(&self.buffer, &self.config.draft_marker)
|
HoverInfo::from_markdown(&self.buffer, &self.config.draft_marker)
|
||||||
.slug
|
.tooltip()
|
||||||
} else {
|
} else {
|
||||||
self.slugs.get(&name).cloned()
|
self.hover_info.get(&name).and_then(HoverInfo::tooltip)
|
||||||
};
|
};
|
||||||
let row = ui
|
let row = ui
|
||||||
.horizontal(|ui| {
|
.horizontal(|ui| {
|
||||||
@@ -1349,8 +1380,8 @@ impl App {
|
|||||||
[ui.available_width(), 20.0],
|
[ui.available_width(), 20.0],
|
||||||
egui::SelectableLabel::new(selected, &name),
|
egui::SelectableLabel::new(selected, &name),
|
||||||
);
|
);
|
||||||
if let Some(slug) = &slug {
|
if let Some(tooltip) = &tooltip {
|
||||||
label = label.on_hover_text(slug);
|
label = label.on_hover_text(tooltip);
|
||||||
}
|
}
|
||||||
if label.clicked() {
|
if label.clicked() {
|
||||||
clicked = Some(idx);
|
clicked = Some(idx);
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub struct Header {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
/// Value of the `# Slug:` line, if present and non-empty.
|
/// Value of the `# Slug:` line, if present and non-empty.
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
|
/// Value of the `POV:` line, if present and non-empty.
|
||||||
|
pub pov: Option<String>,
|
||||||
/// Word-count target from a `Word Count Target:` line, if present and parsable.
|
/// Word-count target from a `Word Count Target:` line, if present and parsable.
|
||||||
pub goal: Option<WordGoal>,
|
pub goal: Option<WordGoal>,
|
||||||
/// The prose body, with comments, the header block, and metadata lines removed.
|
/// The prose body, with comments, the header block, and metadata lines removed.
|
||||||
@@ -39,6 +41,7 @@ pub struct Header {
|
|||||||
enum Meta {
|
enum Meta {
|
||||||
Title,
|
Title,
|
||||||
Slug,
|
Slug,
|
||||||
|
Pov,
|
||||||
WordCount,
|
WordCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +108,7 @@ fn meta_line(line: &str) -> Option<(Meta, String)> {
|
|||||||
for (key, meta) in [
|
for (key, meta) in [
|
||||||
("title:", Meta::Title),
|
("title:", Meta::Title),
|
||||||
("slug:", Meta::Slug),
|
("slug:", Meta::Slug),
|
||||||
|
("pov:", Meta::Pov),
|
||||||
("word count target:", Meta::WordCount),
|
("word count target:", Meta::WordCount),
|
||||||
("word count goal:", Meta::WordCount),
|
("word count goal:", Meta::WordCount),
|
||||||
("word count:", Meta::WordCount),
|
("word count:", Meta::WordCount),
|
||||||
@@ -134,10 +138,12 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
|
|||||||
|
|
||||||
let mut title = None;
|
let mut title = None;
|
||||||
let mut slug = None;
|
let mut slug = None;
|
||||||
|
let mut pov = None;
|
||||||
let mut goal = None;
|
let mut goal = None;
|
||||||
let mut set = |meta: Meta, value: String| match meta {
|
let mut set = |meta: Meta, value: String| match meta {
|
||||||
Meta::Title => title = Some(value),
|
Meta::Title => title = Some(value),
|
||||||
Meta::Slug => slug = Some(value),
|
Meta::Slug => slug = Some(value),
|
||||||
|
Meta::Pov => pov = Some(value),
|
||||||
Meta::WordCount => goal = parse_goal(&value),
|
Meta::WordCount => goal = parse_goal(&value),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +179,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
|
|||||||
Header {
|
Header {
|
||||||
title: title.filter(|s| !s.is_empty()),
|
title: title.filter(|s| !s.is_empty()),
|
||||||
slug: slug.filter(|s| !s.is_empty()),
|
slug: slug.filter(|s| !s.is_empty()),
|
||||||
|
pov: pov.filter(|s| !s.is_empty()),
|
||||||
goal,
|
goal,
|
||||||
body: body_lines.join("\n").trim().to_string(),
|
body: body_lines.join("\n").trim().to_string(),
|
||||||
}
|
}
|
||||||
@@ -265,4 +272,15 @@ mod tests {
|
|||||||
let md = "# Title: Plain\n### Rough Draft:\n\nText.";
|
let md = "# Title: Plain\n### Rough Draft:\n\nText.";
|
||||||
assert_eq!(parse(md, MARKER).goal, None);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user