cd8663349a
- 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>
243 lines
8.4 KiB
Rust
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;
|
|
}
|
|
}
|