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:
+152
-141
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user