Finish bundled-template migration; simplify frontmatter; de-duplicate toolbar/menu

Completes the move off the external cookiecutter tool: drops
src/cookiecutter.rs and the cookiecutter_bin/Template-path setting now
that scaffolding always runs through the built-in MiniJinja engine,
and rewrites default_project_template() to walk up from the executable
without panicking when the bundled template can't be found. Rounds
out the snowflake template with the remaining 06-Revisions,
07-Final, 08-Covers and 09-Archive folders.

Simplifies the scene/chapter YAML frontmatter (drops type/word_count_min/
word_count_max in favor of status + a flat characters list) and updates
the new-file template, header parsing, outline, file list and Story
Bible editor to match.

Separately, de-duplicates the toolbar and menu bar: settings that only
existed as always-visible toolbar controls (draft marker, zero-pad,
spell-check toggle, dictionary picker) move into their Settings
windows, toolbar buttons that exactly duplicated a menu item are
dropped, and toolbar-only actions with no menu path (open project
folder, git sync/init) gain one under File. Also fixes two ambiguous
menu labels: the Story Bible editor now reads "Story Bible editor…"
next to "Story Bible…" in Tools instead of sharing a label with Edit,
and Settings' "New project…" is now "New-project settings…" so it
doesn't read like File's actual create action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:35:52 -05:00
parent c61a7b605a
commit 0f144737a5
23 changed files with 1199 additions and 1091 deletions
+152 -141
View File
@@ -453,6 +453,11 @@ impl App {
}
}
/// Whether the open file has a header to fold away.
pub(super) fn header_is_foldable(&self) -> bool {
header_is_foldable(&self.buffer, &self.config.draft_marker)
}
/// Lift the header out of the buffer, leaving the prose. Supports both
/// YAML frontmatter (`---` delimiters) and the legacy draft marker.
pub(super) fn collapse_header(&mut self) {
@@ -650,11 +655,9 @@ impl App {
self.rebuild_field_names();
}
/// Create a new scene file with merged YAML frontmatter containing both
/// planning metadata and draft content. Used for the "New Scene" button.
/// Create a new scene file: its planning metadata as frontmatter, and the
/// prose below it. Used for the "New Scene" button.
pub(super) fn create_scene_pair(&mut self) {
use super::today_utc;
// Determine which folder we're in
let current_dir = self
.selected
@@ -678,8 +681,6 @@ impl App {
};
let act = act_num.unwrap_or(1);
let today = today_utc();
// Find next available scene number in this act
let existing_scenes: Vec<u32> = self.files.iter()
.filter(|f| {
@@ -717,141 +718,7 @@ impl App {
// Scene file
let filename = format!("{}/scene{:02}.md", draft_dir, scene_num);
// Scene ID
let scene_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
// Create merged file with YAML frontmatter
let content = format!(r#"---
id: {}
title: "{}"
slug: {}
act: {}
chapter: {}
scene: {}
type: scene
status: draft
word_count_target: 2000
word_count_min: 1800
word_count_max: 2200
pov: ""
characters: []
setting: ""
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
version: 1
created: "{}"
updated: "{}"
order: {}
---
# {}
**Scene File** | **ID:** {} | **Status:** Draft
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | {} |
| **Chapter** | {} |
| **Scene** | {} |
| **Type** | Scene |
| **POV** | Not set |
| **Version** | 1 |
| **Word Count Target** | 2000 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
---
## Creative Elements
### POV
> Not specified
### Characters
### Setting
> Not specified
### Date & Time
> Not specified | Not specified
---
## Story Structure
### Conflict
> Not specified
### Theme
> Not specified
### Tone
> Not specified
---
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] Planning complete
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Created:** {} | **Updated:** {}
"#,
scene_id, title, slug, act, chapter, scene_num,
scene_num,
today, today,
title, scene_id,
act, chapter, scene_num,
today, today
);
let content = scene_file_contents(&title, &slug, act, chapter, scene_num);
// Create the merged file
self.insert_new_file(filename.clone(), content);
@@ -1956,6 +1823,51 @@ pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String {
format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@'))
}
/// Whether a document has a header to fold away — YAML frontmatter, or a legacy
/// block above the draft marker.
///
/// Split out from [`App::header_is_foldable`] so the choice can be exercised
/// without an `App`. The two forms are asked about together everywhere the fold
/// is offered, so they are asked about here once: the toggle above the editor
/// used to ask only about the draft marker, which meant a frontmatter file —
/// the one form the app now writes — never offered the fold at all.
fn header_is_foldable(buffer: &str, marker: &str) -> bool {
crate::preprocess::split_yaml_frontmatter(buffer).is_some()
|| crate::preprocess::split_at_marker(buffer, marker).is_some()
}
/// A new scene file: everything it knows about itself in the frontmatter, and
/// nothing below the closing `---` but the prose placeholder.
///
/// The body used to restate the whole header — a metadata table, a word-count
/// table, POV/characters/setting/conflict/theme/tone sections, a notes block and
/// a checklist. All of that is *body*, which is what the word count counts and
/// what the exporter writes into the .odt, so every scene opened a hundred and
/// fifty words and half a page into its own export before a line was written.
fn scene_file_contents(title: &str, slug: &str, act: u32, chapter: u32, scene: u32) -> String {
format!(
"---\n\
title: \"{title}\"\n\
slug: {slug}\n\
act: {act}\n\
chapter: {chapter}\n\
scene: {scene}\n\
status: draft\n\
pov: \"\"\n\
characters: []\n\
setting: \"\"\n\
date: \"\"\n\
time: \"\"\n\
conflict: \"\"\n\
theme: \"\"\n\
tone: \"\"\n\
word_count_target: 1800-2200\n\
---\n\
\n\
[Write your scene content here...]\n"
)
}
/// Expand the template placeholders and guarantee a trailing newline.
pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String {
let mut out = template
@@ -1972,6 +1884,104 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
mod tests {
use super::*;
#[test]
fn a_frontmatter_scene_offers_the_fold() {
// An explicit marker: the default is empty now that scenes are written
// as frontmatter, so it would not exercise the legacy arm.
let marker = "### Rough Draft:";
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
assert!(header_is_foldable(&scene, marker), "frontmatter should fold");
// A frontmatter file has no draft marker, which is exactly why asking
// only about the marker hid the toggle on every scene the app writes.
assert!(crate::preprocess::split_at_marker(&scene, marker).is_none());
// The legacy form still folds, and prose alone still does not.
let legacy = "# Title: The Gate\n\n### Rough Draft:\n\nProse.\n";
assert!(header_is_foldable(legacy, marker), "a draft marker should fold");
assert!(!header_is_foldable("Just prose.\n", marker), "nothing to fold");
// Clearing the marker disables legacy splitting but not frontmatter,
// which is delimited by `---` rather than by the marker.
assert!(!header_is_foldable(legacy, ""));
assert!(header_is_foldable(&scene, ""));
}
#[test]
fn folding_a_scene_leaves_the_prose_and_keeps_the_file_whole() {
// The fold is a view: `document()` glues the stash back on for every
// save, so what is on disk must not change for having been folded.
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
let (header, body) =
crate::preprocess::split_yaml_frontmatter(&scene).expect("frontmatter");
assert!(
!body.contains("word_count_target"),
"no frontmatter left in the editor: {body:?}"
);
assert_eq!(body.trim(), "[Write your scene content here...]");
assert_eq!(format!("{header}{body}"), scene, "the file is unchanged");
// What the toggle's label counts while folded.
assert_eq!(crate::preprocess::fields(header, "").len(), 15);
}
/// The template the snowflake project ships and the file "+ New Scene"
/// writes have to stay the same shape, so check the bundled one too.
const BUNDLED_TEMPLATE: &str =
include_str!("../../templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md");
#[test]
fn a_new_scene_puts_nothing_but_prose_below_the_frontmatter() {
// The body is what the word count counts and what the exporter writes,
// so the header must not be restated in it. It used to be, at length.
for source in [
scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 1, 1, 3),
BUNDLED_TEMPLATE.to_string(),
] {
let header = crate::preprocess::parse(&source, "### Rough Draft:");
assert_eq!(header.body, "[Write your scene content here...]");
assert!(
count_words(&header.body) < 10,
"a new scene should not open with a page of metadata"
);
}
}
#[test]
fn a_new_scene_says_each_thing_once() {
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
let fields = crate::preprocess::fields(&source, "### Rough Draft:");
let mut names: Vec<String> = fields.iter().map(|f| f.name.to_lowercase()).collect();
names.sort();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "a key is written twice: {names:?}");
// Nothing the app cannot keep true: these were stale the moment you
// typed, and `order`/`id` restated order.json and the file's own path.
for gone in [
"id", "type", "version", "order", "created", "updated", "word_count",
"word_count_updated", "session_word_count", "last_session", "total_sessions",
] {
assert!(!names.contains(&gone.to_string()), "{gone} is back");
}
}
#[test]
fn a_new_scene_is_read_back_through_the_frontmatter() {
// The point of favouring YAML: every reader gets its answer from there.
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
let header = crate::preprocess::parse(&source, "### Rough Draft:");
assert_eq!(header.title.as_deref(), Some("Chapter 1: Scene 3"));
assert_eq!(header.slug.as_deref(), Some("chapter-1--scene-3"));
let goal = header.goal.expect("the target is readable");
assert_eq!((goal.min, goal.max), (1800, 2200));
assert_eq!(
crate::preprocess::field(&source, "", "status").as_deref(),
Some("draft")
);
assert_eq!(crate::preprocess::field(&source, "", "act").as_deref(), Some("2"));
// Unfilled reads as absent, so an empty `pov:` is not a POV of "".
assert_eq!(crate::preprocess::field(&source, "", "pov"), None);
assert_eq!(crate::preprocess::field(&source, "", "characters"), None);
}
/// The folder the "open the project folder" button points at, across the
/// shapes a workspace can take.
#[test]
@@ -2284,3 +2294,4 @@ mod tests {
assert_eq!(nearest_listed(&[], Some("05-First Draft"), 0), None);
}
}