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>
This commit is contained in:
+103
-21
@@ -9,6 +9,107 @@ pub(super) struct OutlineFile {
|
|||||||
pub beats: Vec<crate::outline::Beat>,
|
pub beats: Vec<crate::outline::Beat>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<BeatTree> },
|
||||||
|
/// 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<BeatTree> {
|
||||||
|
let mut tree: Vec<BeatTree> = 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<String> = None;
|
||||||
|
let mut current_children: Vec<BeatTree> = 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 {
|
impl App {
|
||||||
/// The project's outline folder, looked for beside the workspace and up
|
/// The project's outline folder, looked for beside the workspace and up
|
||||||
/// through its ancestors, the same way the character folder is found.
|
/// through its ancestors, the same way the character folder is found.
|
||||||
@@ -108,6 +209,7 @@ impl App {
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for file in &files {
|
for file in &files {
|
||||||
let (done, total) = crate::outline::progress(&file.beats);
|
let (done, total) = crate::outline::progress(&file.beats);
|
||||||
|
let tree = build_beat_tree(&file.beats);
|
||||||
egui::CollapsingHeader::new(format!(
|
egui::CollapsingHeader::new(format!(
|
||||||
"{} ({done}/{total})",
|
"{} ({done}/{total})",
|
||||||
file.rel
|
file.rel
|
||||||
@@ -118,27 +220,7 @@ impl App {
|
|||||||
if ui.link("open this file").clicked() {
|
if ui.link("open this file").clicked() {
|
||||||
open_file = Some(file.rel.clone());
|
open_file = Some(file.rel.clone());
|
||||||
}
|
}
|
||||||
let mut section = "";
|
render_beat_tree(ui, &tree);
|
||||||
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()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+52
-10
@@ -25,29 +25,36 @@ pub struct Beat {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// Whether any prose has been written for it.
|
/// Whether any prose has been written for it.
|
||||||
pub filled: bool,
|
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.
|
/// Read an outline file's beats and their state.
|
||||||
///
|
///
|
||||||
/// A beat is "filled" when there is ordinary text between its prompt comment
|
/// 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.
|
/// 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> {
|
pub fn beats(markdown: &str) -> Vec<Beat> {
|
||||||
let mut out: Vec<Beat> = Vec::new();
|
let mut out: Vec<Beat> = Vec::new();
|
||||||
let mut section = String::new();
|
|
||||||
let mut rest = markdown;
|
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.
|
// Text seen since the last prompt, which decides whether it is filled.
|
||||||
let mut pending_prose = String::new();
|
let mut pending_prose = String::new();
|
||||||
|
|
||||||
while let Some(start) = rest.find("<!--") {
|
while let Some(start) = rest.find("<!--") {
|
||||||
let before = &rest[..start];
|
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.
|
// Anything before this comment belongs to the previous beat.
|
||||||
pending_prose.push_str(before);
|
pending_prose.push_str(before);
|
||||||
if let Some(heading) = last_heading(before) {
|
|
||||||
// A heading closes the previous beat and opens a new section.
|
|
||||||
settle(&mut out, &mut pending_prose);
|
|
||||||
section = heading;
|
|
||||||
}
|
|
||||||
settle(&mut out, &mut pending_prose);
|
settle(&mut out, &mut pending_prose);
|
||||||
|
|
||||||
let Some(end) = rest[start + 4..].find("-->") else {
|
let Some(end) = rest[start + 4..].find("-->") else {
|
||||||
@@ -55,23 +62,58 @@ pub fn beats(markdown: &str) -> Vec<Beat> {
|
|||||||
};
|
};
|
||||||
let body = &rest[start + 4..start + 4 + end];
|
let body = &rest[start + 4..start + 4 + end];
|
||||||
if let Some(name) = prompt_name(body) {
|
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 {
|
out.push(Beat {
|
||||||
section: section.clone(),
|
section: section.clone(),
|
||||||
name,
|
name,
|
||||||
filled: false,
|
filled: false,
|
||||||
|
depth,
|
||||||
|
path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
rest = &rest[start + 4 + end + 3..];
|
rest = &rest[start + 4 + end + 3..];
|
||||||
}
|
}
|
||||||
pending_prose.push_str(rest);
|
pending_prose.push_str(rest);
|
||||||
if let Some(heading) = last_heading(rest) {
|
// Process any remaining headings
|
||||||
settle(&mut out, &mut pending_prose);
|
process_headings(rest, &mut heading_stack);
|
||||||
let _ = heading; // a trailing heading opens nothing
|
|
||||||
}
|
|
||||||
settle(&mut out, &mut pending_prose);
|
settle(&mut out, &mut pending_prose);
|
||||||
out
|
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.
|
/// Credit whatever prose has accumulated to the most recent beat, then clear it.
|
||||||
fn settle(beats: &mut [Beat], pending: &mut String) {
|
fn settle(beats: &mut [Beat], pending: &mut String) {
|
||||||
if has_prose(pending) {
|
if has_prose(pending) {
|
||||||
|
|||||||
Reference in New Issue
Block a user