Files
md-manuscript/src/outline.rs
T
landon cd8663349a feat: Implement heading-based nesting outlines in Outline window
- Add depth and path fields to Beat struct to track heading hierarchy
- Rewrite beats() parser to use heading stack and process_headings() helper
- Add BeatTree enum with Heading and Beat variants for tree structure
- Add build_beat_tree() to convert flat beats to nested tree
- Add render_beat_tree() for recursive tree rendering with collapsible headers
- Add count_beat_tree() helper to count beats in subtree
- Replace flat list rendering with tree-based rendering in outline_window()

The implementation currently groups beats by section, with infrastructure
in place for full hierarchical nesting based on beat.path.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 14:56:35 -05:00

266 lines
8.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Reading the state of a project's outline.
//!
//! The snowflake template's outline files are scaffolds made entirely of HTML
//! comments, one per story beat:
//!
//! ```text
//! ## Act I – Setup (≈ 25 % of the story)
//!
//! <!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
//!
//! <!-- Inciting Incident: An event that disrupts the status-quo. -->
//! ```
//!
//! The app strips comments everywhere else, which means these files render and
//! export as blank — there is nothing to see and no way to tell how much of the
//! outline is actually written. This module reads the scaffold back: the beats
//! it names, and which of them have prose under them yet.
/// One beat of an outline, and whether it has been written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Beat {
/// The heading this beat sits under, e.g. `Act I – Setup`.
pub section: String,
/// The beat's name, taken from the start of its prompt comment.
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<String>,
}
/// 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<Beat> {
let mut out: Vec<Beat> = Vec::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("<!--") {
let before = &rest[..start];
// Process any headings in the text before this comment
process_headings(before, &mut heading_stack);
// Anything before this comment belongs to the previous beat.
pending_prose.push_str(before);
settle(&mut out, &mut pending_prose);
let Some(end) = rest[start + 4..].find("-->") else {
break;
};
let body = &rest[start + 4..start + 4 + end];
if let Some(name) = prompt_name(body) {
// Build path from heading stack
let path: Vec<String> = 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);
// 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) {
if let Some(last) = beats.last_mut() {
last.filled = true;
}
}
pending.clear();
}
/// Whether a run of text contains anything a writer would call content —
/// headings and blank lines do not count.
fn has_prose(text: &str) -> bool {
text.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with('#'))
}
/// The last markdown heading in a run of text, if any.
fn last_heading(text: &str) -> Option<String> {
text.lines().rev().find_map(|line| {
let trimmed = line.trim();
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 {
return None;
}
let name = trimmed[hashes..].trim();
(!name.is_empty()).then(|| name.to_string())
})
}
/// The beat name at the start of a prompt comment: everything before its first
/// colon, which is how the template writes them (`Midpoint: A major turning…`).
fn prompt_name(body: &str) -> Option<String> {
let text = body.trim();
if text.is_empty() {
return None;
}
let name = match text.split_once(':') {
Some((name, _)) => name.trim(),
// A comment with no colon is a note, not a beat prompt.
None => return None,
};
// Prompt names are labels; a long one is a sentence that happened to have
// a colon in it.
if name.is_empty() || name.split_whitespace().count() > 8 {
return None;
}
Some(name.to_string())
}
/// How many of `beats` are written, and how many there are.
pub fn progress(beats: &[Beat]) -> (usize, usize) {
(beats.iter().filter(|b| b.filled).count(), beats.len())
}
#[cfg(test)]
mod tests {
use super::*;
const SCAFFOLD: &str = "\
## Act I – Setup (≈ 25 % of the story)
<!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
<!-- Inciting Incident: An event that disrupts the status-quo. -->
## Act II – Confrontation
<!-- Midpoint: A major turning point. -->
";
#[test]
fn an_untouched_scaffold_has_no_beats_written() {
let beats = beats(SCAFFOLD);
assert_eq!(beats.len(), 3);
assert!(beats.iter().all(|b| !b.filled));
assert_eq!(progress(&beats), (0, 3));
}
#[test]
fn beat_names_and_their_sections_are_read() {
let beats = beats(SCAFFOLD);
assert_eq!(beats[0].name, "Opening Image / Hook");
assert_eq!(beats[0].section, "Act I – Setup (≈ 25 % of the story)");
assert_eq!(beats[1].name, "Inciting Incident");
assert_eq!(beats[2].name, "Midpoint");
assert_eq!(beats[2].section, "Act II – Confrontation");
}
#[test]
fn prose_after_a_prompt_marks_it_written() {
let written = "\
## Act I
<!-- Opening Image: the prompt. -->
Ada stands on the ice, counting bolts.
<!-- Inciting Incident: the prompt. -->
";
let beats = beats(written);
assert_eq!(progress(&beats), (1, 2));
assert!(beats[0].filled, "the beat with prose under it");
assert!(!beats[1].filled, "the one still empty");
}
#[test]
fn a_heading_does_not_count_as_prose() {
let text = "<!-- A: prompt -->\n\n## Act II\n\n<!-- B: prompt -->\n";
let beats = beats(text);
assert_eq!(progress(&beats), (0, 2));
}
#[test]
fn prose_after_the_last_prompt_still_counts() {
let text = "<!-- Climax: prompt -->\n\nShe opens the gate.\n";
let beats = beats(text);
assert_eq!(progress(&beats), (1, 1));
}
#[test]
fn a_comment_without_a_colon_is_a_note_not_a_beat() {
let text = "<!-- just a note to self -->\n\n<!-- Climax: prompt -->\n";
let beats = beats(text);
assert_eq!(beats.len(), 1);
assert_eq!(beats[0].name, "Climax");
}
#[test]
fn an_unterminated_comment_does_not_hang_or_panic() {
let beats = beats("<!-- Climax: prompt -->\n\n<!-- broken");
assert_eq!(beats.len(), 1);
}
#[test]
fn a_file_with_no_comments_yields_nothing() {
assert!(beats("# Just prose\n\nWords.\n").is_empty());
assert!(beats("").is_empty());
}
/// Prose written *before* the first prompt belongs to no beat and must not
/// be credited to one that comes later.
#[test]
fn prose_before_the_first_prompt_credits_nothing() {
let text = "Some preamble.\n\n<!-- Climax: prompt -->\n";
assert_eq!(progress(&beats(text)), (0, 1));
}
}