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
+44 -38
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Persistent application configuration (which workspace to open, plus UI prefs).
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -85,16 +85,15 @@ pub struct Config {
/// and `{{date}}` to today's UTC date. Blank = the built-in default.
#[serde(default = "default_new_file_template")]
pub new_file_template: String,
/// Directory holding the cookiecutter template used by **File ▸ New
/// project…** (the folder containing `cookiecutter.json`).
/// Directory holding the template used by **File ▸ New project…** — the
/// folder containing `cookiecutter.json`. Rendered by the built-in
/// MiniJinja engine; the manifest keeps the cookiecutter name so existing
/// templates still work.
#[serde(default = "default_project_template")]
pub project_template: PathBuf,
/// Path to the `cookiecutter` executable. Blank means look on `PATH` and in
/// the usual per-user Python prefixes.
#[serde(default)]
pub cookiecutter_bin: String,
/// Subfolder of a freshly generated project to open as the workspace. Blank
/// (or a folder the template did not create) opens the project root.
/// Which folder of a project holds the book — the manuscript folder. A
/// generated project always opens at its root; this names the folder inside
/// it whose files are ordered, numbered and exported.
#[serde(default = "default_project_open_subdir")]
pub project_open_subdir: String,
/// Whether to run the template's pre/post-generation hooks. The snowflake
@@ -184,39 +183,46 @@ pub fn default_show_toolbar() -> bool {
true
}
/// Default cookiecutter template for **File ▸ New project…**.
/// Uses the bundled template from the project's templates/snowflake directory.
/// Where the bundled project template lives, for **File ▸ New project…**.
///
/// Walks up from the executable and then tries the working directory, which
/// covers `cargo run`, a release build run in place, and an installed binary
/// with the templates beside it.
///
/// Returns a path even when nothing is found, rather than failing: this is the
/// default for a config field, so it is reached on any machine that has no
/// `config.json` yet — and an installed binary without the templates folder
/// beside it is an ordinary way to arrive here, not a reason to refuse to
/// start. The path that comes back simply has no `cookiecutter.json` in it,
/// which the settings dialog reports ("✖ no cookiecutter.json in that folder")
/// and project generation refuses with a message naming the folder.
pub fn default_project_template() -> PathBuf {
// Find the bundled template relative to the executable's location
if let Ok(exe_path) = std::env::current_exe() {
// Walk up from the executable's directory looking for templates/snowflake/cookiecutter.json
// This handles various build configurations (release, debug, cargo run, installed binary)
let mut current = exe_path.parent().unwrap_or_else(|| exe_path.as_path());
loop {
// Check if current/templates/snowflake/cookiecutter.json exists
let candidate = current.join("templates/snowflake");
if candidate.join("cookiecutter.json").is_file() {
return candidate;
}
// Try going up one level
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
// Also try relative to current working directory
if let Ok(cwd) = std::env::current_dir() {
let candidate = cwd.join("templates/snowflake");
const BUNDLED: &str = "templates/snowflake";
let exe_dir = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf));
if let Some(dir) = exe_dir.as_deref() {
for ancestor in dir.ancestors() {
let candidate = ancestor.join(BUNDLED);
if candidate.join("cookiecutter.json").is_file() {
return candidate;
}
}
}
// If we can't find the bundled template, panic with a clear error
// The bundled template should always be present in a properly built release
panic!("Bundled template not found. Please ensure templates/snowflake/cookiecutter.json exists relative to the executable.");
if let Ok(cwd) = std::env::current_dir() {
let candidate = cwd.join(BUNDLED);
if candidate.join("cookiecutter.json").is_file() {
return candidate;
}
}
// Nothing found: name where the templates are expected to be, so the
// message the user eventually sees points at a useful folder.
match exe_dir {
Some(dir) => dir.join(BUNDLED),
None => PathBuf::from(BUNDLED),
}
}
/// Default subfolder opened after generating a project: the snowflake
@@ -262,7 +268,9 @@ pub fn default_new_file_template() -> String {
"---",
"title: {{name}}",
"slug: ",
"status: draft",
"pov: ",
"characters: []",
"word_count_target: ",
"---",
"",
@@ -344,7 +352,6 @@ impl Default for Config {
mistral_base_url: default_mistral_base_url(),
new_file_template: default_new_file_template(),
project_template: default_project_template(),
cookiecutter_bin: String::new(),
project_open_subdir: default_project_open_subdir(),
project_run_hooks: default_project_run_hooks(),
projects_dir: default_projects_dir(),
@@ -560,7 +567,6 @@ mod tests {
// Credentials are absent until set; blank is what tells a hook to skip.
assert!(cfg.gitea_url.is_empty());
assert!(cfg.gitea_token.is_empty());
assert!(cfg.cookiecutter_bin.is_empty());
}
/// The folder opened after generating a project. It is the snowflake