Files
md-manuscript/src/app/outline.rs
T
landon 043cc692ac Add project-mode tooling: characters, outline, diff and revision status
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
2026-08-24 19:56:36 -05:00

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;
}
}