//! 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, } 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); 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()); } 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() }); } }); } }); 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; } }