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:
landon
2026-09-15 14:56:35 -05:00
parent 921f3e74c5
commit cd8663349a
2 changed files with 156 additions and 32 deletions
+103 -21
View File
@@ -9,6 +9,107 @@ pub(super) struct OutlineFile {
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 {
/// 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);
});
}
});