Files
md-manuscript/src/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

224 lines
7.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
}