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
This commit is contained in:
2026-08-24 19:56:36 -05:00
parent e951d57d45
commit 043cc692ac
23 changed files with 3879 additions and 144 deletions
+223
View File
@@ -0,0 +1,223 @@
//! Reading the state of a project's outline.
//!
//! The snowflake template's outline files are scaffolds made entirely of HTML
//! comments, one per story beat:
//!
//! ```text
//! ## Act I – Setup (≈ 25 % of the story)
//!
//! <!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
//!
//! <!-- Inciting Incident: An event that disrupts the status-quo. -->
//! ```
//!
//! The app strips comments everywhere else, which means these files render and
//! export as blank — there is nothing to see and no way to tell how much of the
//! outline is actually written. This module reads the scaffold back: the beats
//! it names, and which of them have prose under them yet.
/// One beat of an outline, and whether it has been written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Beat {
/// The heading this beat sits under, e.g. `Act I – Setup`.
pub section: String,
/// The beat's name, taken from the start of its prompt comment.
pub name: String,
/// Whether any prose has been written for it.
pub filled: bool,
}
/// Read an outline file's beats and their state.
///
/// A beat is "filled" when there is ordinary text between its prompt comment
/// and the next prompt or heading — which is exactly where you would type it.
pub fn beats(markdown: &str) -> Vec<Beat> {
let mut out: Vec<Beat> = Vec::new();
let mut section = String::new();
let mut rest = markdown;
// Text seen since the last prompt, which decides whether it is filled.
let mut pending_prose = String::new();
while let Some(start) = rest.find("<!--") {
let before = &rest[..start];
// Anything before this comment belongs to the previous beat.
pending_prose.push_str(before);
if let Some(heading) = last_heading(before) {
// A heading closes the previous beat and opens a new section.
settle(&mut out, &mut pending_prose);
section = heading;
}
settle(&mut out, &mut pending_prose);
let Some(end) = rest[start + 4..].find("-->") else {
break;
};
let body = &rest[start + 4..start + 4 + end];
if let Some(name) = prompt_name(body) {
out.push(Beat {
section: section.clone(),
name,
filled: false,
});
}
rest = &rest[start + 4 + end + 3..];
}
pending_prose.push_str(rest);
if let Some(heading) = last_heading(rest) {
settle(&mut out, &mut pending_prose);
let _ = heading; // a trailing heading opens nothing
}
settle(&mut out, &mut pending_prose);
out
}
/// Credit whatever prose has accumulated to the most recent beat, then clear it.
fn settle(beats: &mut [Beat], pending: &mut String) {
if has_prose(pending) {
if let Some(last) = beats.last_mut() {
last.filled = true;
}
}
pending.clear();
}
/// Whether a run of text contains anything a writer would call content —
/// headings and blank lines do not count.
fn has_prose(text: &str) -> bool {
text.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with('#'))
}
/// The last markdown heading in a run of text, if any.
fn last_heading(text: &str) -> Option<String> {
text.lines().rev().find_map(|line| {
let trimmed = line.trim();
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 {
return None;
}
let name = trimmed[hashes..].trim();
(!name.is_empty()).then(|| name.to_string())
})
}
/// The beat name at the start of a prompt comment: everything before its first
/// colon, which is how the template writes them (`Midpoint: A major turning…`).
fn prompt_name(body: &str) -> Option<String> {
let text = body.trim();
if text.is_empty() {
return None;
}
let name = match text.split_once(':') {
Some((name, _)) => name.trim(),
// A comment with no colon is a note, not a beat prompt.
None => return None,
};
// Prompt names are labels; a long one is a sentence that happened to have
// a colon in it.
if name.is_empty() || name.split_whitespace().count() > 8 {
return None;
}
Some(name.to_string())
}
/// How many of `beats` are written, and how many there are.
pub fn progress(beats: &[Beat]) -> (usize, usize) {
(beats.iter().filter(|b| b.filled).count(), beats.len())
}
#[cfg(test)]
mod tests {
use super::*;
const SCAFFOLD: &str = "\
## Act I – Setup (≈ 25 % of the story)
<!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
<!-- Inciting Incident: An event that disrupts the status-quo. -->
## Act II – Confrontation
<!-- Midpoint: A major turning point. -->
";
#[test]
fn an_untouched_scaffold_has_no_beats_written() {
let beats = beats(SCAFFOLD);
assert_eq!(beats.len(), 3);
assert!(beats.iter().all(|b| !b.filled));
assert_eq!(progress(&beats), (0, 3));
}
#[test]
fn beat_names_and_their_sections_are_read() {
let beats = beats(SCAFFOLD);
assert_eq!(beats[0].name, "Opening Image / Hook");
assert_eq!(beats[0].section, "Act I – Setup (≈ 25 % of the story)");
assert_eq!(beats[1].name, "Inciting Incident");
assert_eq!(beats[2].name, "Midpoint");
assert_eq!(beats[2].section, "Act II – Confrontation");
}
#[test]
fn prose_after_a_prompt_marks_it_written() {
let written = "\
## Act I
<!-- Opening Image: the prompt. -->
Ada stands on the ice, counting bolts.
<!-- Inciting Incident: the prompt. -->
";
let beats = beats(written);
assert_eq!(progress(&beats), (1, 2));
assert!(beats[0].filled, "the beat with prose under it");
assert!(!beats[1].filled, "the one still empty");
}
#[test]
fn a_heading_does_not_count_as_prose() {
let text = "<!-- A: prompt -->\n\n## Act II\n\n<!-- B: prompt -->\n";
let beats = beats(text);
assert_eq!(progress(&beats), (0, 2));
}
#[test]
fn prose_after_the_last_prompt_still_counts() {
let text = "<!-- Climax: prompt -->\n\nShe opens the gate.\n";
let beats = beats(text);
assert_eq!(progress(&beats), (1, 1));
}
#[test]
fn a_comment_without_a_colon_is_a_note_not_a_beat() {
let text = "<!-- just a note to self -->\n\n<!-- Climax: prompt -->\n";
let beats = beats(text);
assert_eq!(beats.len(), 1);
assert_eq!(beats[0].name, "Climax");
}
#[test]
fn an_unterminated_comment_does_not_hang_or_panic() {
let beats = beats("<!-- Climax: prompt -->\n\n<!-- broken");
assert_eq!(beats.len(), 1);
}
#[test]
fn a_file_with_no_comments_yields_nothing() {
assert!(beats("# Just prose\n\nWords.\n").is_empty());
assert!(beats("").is_empty());
}
/// Prose written *before* the first prompt belongs to no beat and must not
/// be credited to one that comes later.
#[test]
fn prose_before_the_first_prompt_credits_nothing() {
let text = "Some preamble.\n\n<!-- Climax: prompt -->\n";
assert_eq!(progress(&beats(text)), (0, 1));
}
}