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>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user