//! The Outline window: how much of the project's outline is actually written. use super::*; /// One outline file and the state of the beats in it. pub(super) struct OutlineFile { /// Workspace-relative path, so it can be opened in the editor. pub rel: String, 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. pub(super) fn outline_dir(&self) -> Option { const MAX_UP: usize = 3; let mut dir = Some(self.workspace()); for _ in 0..=MAX_UP { let current = dir?; if let Some(name) = order::child_dir_matching(current, "Outline") { return Some(current.join(name)); } dir = current.parent(); } None } /// Read every outline file and the beats in it. pub(super) fn read_outline(&self) -> Vec { let Some(dir) = self.outline_dir() else { return Vec::new(); }; let mut out = Vec::new(); for path in markdown_files_under(&dir) { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; let beats = crate::outline::beats(&text); if beats.is_empty() { continue; } let rel = self .relative_to_workspace(&path) .unwrap_or_else(|| path.display().to_string()); out.push(OutlineFile { rel, beats }); } out } /// The Outline window: a checklist of the beats the scaffold names. pub(super) fn outline_window(&mut self, ctx: &egui::Context) { let mut open = self.show_outline; let mut close = false; let mut open_file: Option = None; let files = self.read_outline(); egui::Window::new("Outline") .open(&mut open) .resizable(true) .collapsible(false) .default_width(440.0) .show(ctx, |ui| { match self.outline_dir() { Some(dir) => { ui.label( egui::RichText::new(format!("Scaffold in {}", dir.display())) .small() .weak(), ); } None => { ui.label( egui::RichText::new( "No outline folder found near this workspace.", ) .weak(), ); } } if files.is_empty() { ui.label( egui::RichText::new( "No beat prompts found. The template writes them as HTML \ comments like .", ) .small() .weak(), ); } let total: usize = files.iter().map(|f| f.beats.len()).sum(); let done: usize = files .iter() .map(|f| crate::outline::progress(&f.beats).0) .sum(); if total > 0 { ui.add_space(4.0); ui.add( egui::ProgressBar::new(done as f32 / total as f32) .text(format!("{done} of {total} beats written")), ); } ui.separator(); egui::ScrollArea::vertical() .auto_shrink([false, true]) .max_height(420.0) .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 )) .id_salt(&file.rel) .default_open(files.len() == 1) .show(ui, |ui| { if ui.link("open this file").clicked() { open_file = Some(file.rel.clone()); } render_beat_tree(ui, &tree); }); } }); ui.separator(); if ui.button("Close").clicked() { close = true; } }); if let Some(path) = open_file { match self.files.iter().position(|f| *f == path) { Some(idx) => self.select(idx), None => self.status = format!("{path} is not in this workspace"), } } self.show_outline = open && !close; } }