043cc692ac
Rounds out project mode, where the workspace is the project root and one subfolder holds the manuscript proper: - Characters and Outline windows, backed by new `characters` and `outline` modules that read the cast from character sheets and measure how much of the snowflake outline is actually written. - Edit ▸ Changes… diffs the open file against its last committed version. - Revision status, per-file and project word counts, an archive action and hidden folders in the file panel. - Chapter-file export alongside the ODT master, richer header parsing, and a project word list for names and invented terms. - The export path now follows the workspace: opening a project points it at that project root, keeping a file name you chose yourself and re-deriving one that merely echoed the folder it sat in. - Clicking an issue in the grammar/spelling panel takes the editor to it, selecting the words and centring them; applying a suggestion jumps to the rewritten text as well. README covers the new windows and workflows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
161 lines
6.2 KiB
Rust
161 lines
6.2 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>,
|
|
}
|
|
|
|
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);
|
|
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;
|
|
}
|
|
}
|