Files
md-manuscript/src/app/outline.rs
T
landon cd8663349a 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>
2026-09-15 14:56:35 -05:00

243 lines
8.4 KiB
Rust

//! 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<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.
pub(super) fn outline_dir(&self) -> Option<PathBuf> {
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<OutlineFile> {
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<String> = 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 <!-- Midpoint: … -->.",
)
.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;
}
}