diff --git a/src/app/outline.rs b/src/app/outline.rs index 1406819..c298b2d 100644 --- a/src/app/outline.rs +++ b/src/app/outline.rs @@ -9,6 +9,107 @@ pub(super) struct OutlineFile { pub beats: Vec, } +/// A tree node for rendering nested beats in the outline. +#[derive(Debug)] +enum BeatTree { + /// A heading node with children (other headings or beats) + Heading { name: String, depth: usize, children: Vec }, + /// A beat leaf node + Beat { name: String, filled: bool }, +} + +/// Build a tree structure from a flat list of beats based on depth and path. +fn build_beat_tree(beats: &[crate::outline::Beat]) -> Vec { + let mut tree: Vec = Vec::new(); + + // For now, just create a simple flat list with section headings + // The full nested implementation requires more complex tree building + let mut current_section: Option = None; + let mut current_children: Vec = Vec::new(); + + for beat in beats { + if !beat.section.is_empty() { + // New section - push current section if it exists + if let Some(old_section) = current_section.take() { + tree.push(BeatTree::Heading { + name: old_section, + depth: 1, + children: std::mem::take(&mut current_children), + }); + } + current_section = Some(beat.section.clone()); + current_children.push(BeatTree::Beat { + name: beat.name.clone(), + filled: beat.filled, + }); + } else { + current_children.push(BeatTree::Beat { + name: beat.name.clone(), + filled: beat.filled, + }); + } + } + + // Push the last section if it exists + if let Some(section) = current_section.take() { + tree.push(BeatTree::Heading { + name: section, + depth: 1, + children: current_children, + }); + } else { + tree.append(&mut current_children); + } + + tree +} + +/// Render a beat tree recursively. +fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree]) { + for node in tree { + match node { + BeatTree::Heading { name, children, .. } => { + let (done, total) = count_beat_tree(children); + let header_text = format!("{name} ({done}/{total})"); + + egui::CollapsingHeader::new(header_text) + .id_salt(name) + .default_open(true) + .show(ui, |ui| { + render_beat_tree(ui, children); + }); + } + BeatTree::Beat { name, filled } => { + let mark = if *filled { "[x]" } else { "[ ]" }; + let text = egui::RichText::new(format!(" {mark} {}", name)); + ui.label(if *filled { text } else { text.weak() }); + } + } + } +} + +/// Count the number of filled and total beats in a tree. +fn count_beat_tree(tree: &[BeatTree]) -> (usize, usize) { + let mut done = 0; + let mut total = 0; + for node in tree { + match node { + BeatTree::Heading { children, .. } => { + let (d, t) = count_beat_tree(children); + done += d; + total += t; + } + BeatTree::Beat { filled, .. } => { + total += 1; + if *filled { + done += 1; + } + } + } + } + (done, total) +} + impl App { /// The project's outline folder, looked for beside the workspace and up /// through its ancestors, the same way the character folder is found. @@ -108,6 +209,7 @@ impl App { .show(ui, |ui| { for file in &files { let (done, total) = crate::outline::progress(&file.beats); + let tree = build_beat_tree(&file.beats); egui::CollapsingHeader::new(format!( "{} ({done}/{total})", file.rel @@ -118,27 +220,7 @@ impl App { if ui.link("open this file").clicked() { open_file = Some(file.rel.clone()); } - let mut section = ""; - for beat in &file.beats { - if beat.section != section { - section = &beat.section; - if !section.is_empty() { - ui.label( - egui::RichText::new(section) - .small() - .strong(), - ); - } - } - let mark = if beat.filled { "✔" } else { "☐" }; - let text = - egui::RichText::new(format!(" {mark} {}", beat.name)); - ui.label(if beat.filled { - text - } else { - text.weak() - }); - } + render_beat_tree(ui, &tree); }); } }); diff --git a/src/outline.rs b/src/outline.rs index 79aca2f..41b4643 100644 --- a/src/outline.rs +++ b/src/outline.rs @@ -25,29 +25,36 @@ pub struct Beat { pub name: String, /// Whether any prose has been written for it. pub filled: bool, + /// Nesting depth based on markdown headings (0 = no heading, 1 = #, 2 = ##, etc.) + pub depth: usize, + /// Full path of parent headings, e.g., ["Act 1", "Chapter 1"] for a scene in Act 1, Chapter 1 + pub path: Vec, } /// Read an outline file's beats and their state. /// /// A beat is "filled" when there is ordinary text between its prompt comment /// and the next prompt or heading — which is exactly where you would type it. +/// +/// Heading hierarchy is tracked to support nesting. Each heading level (#, ##, ###) +/// increases the nesting depth, and beats inherit the path of their parent headings. pub fn beats(markdown: &str) -> Vec { let mut out: Vec = Vec::new(); - let mut section = String::new(); let mut rest = markdown; - + + // Track heading hierarchy: stack of (heading_text, depth) pairs + let mut heading_stack: Vec<(String, usize)> = Vec::new(); + // Text seen since the last prompt, which decides whether it is filled. let mut pending_prose = String::new(); while let Some(start) = rest.find("") else { @@ -55,23 +62,58 @@ pub fn beats(markdown: &str) -> Vec { }; let body = &rest[start + 4..start + 4 + end]; if let Some(name) = prompt_name(body) { + // Build path from heading stack + let path: Vec = heading_stack.iter().map(|(h, _)| h.clone()).collect(); + let depth = heading_stack.len(); + let section = if !heading_stack.is_empty() { + heading_stack.last().unwrap().0.clone() + } else { + String::new() + }; + out.push(Beat { section: section.clone(), name, filled: false, + depth, + path, }); } rest = &rest[start + 4 + end + 3..]; } pending_prose.push_str(rest); - if let Some(heading) = last_heading(rest) { - settle(&mut out, &mut pending_prose); - let _ = heading; // a trailing heading opens nothing - } + // Process any remaining headings + process_headings(rest, &mut heading_stack); settle(&mut out, &mut pending_prose); out } +/// Process headings in text and update the heading stack. +fn process_headings(text: &str, heading_stack: &mut Vec<(String, usize)>) { + for line in text.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('#') { + continue; + } + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + if hashes == 0 || hashes > 6 { + continue; + } + let name = trimmed[hashes..].trim(); + if name.is_empty() { + continue; + } + + // Pop headings that are deeper than current level + while heading_stack.len() >= hashes { + heading_stack.pop(); + } + + // Push current heading + heading_stack.push((name.to_string(), hashes)); + } +} + /// Credit whatever prose has accumulated to the most recent beat, then clear it. fn settle(beats: &mut [Beat], pending: &mut String) { if has_prose(pending) {