From 0f144737a52dd73559e1bba070fda86e127615fc Mon Sep 17 00:00:00 2001 From: landon Date: Sat, 19 Sep 2026 10:35:52 -0500 Subject: [PATCH] Finish bundled-template migration; simplify frontmatter; de-duplicate toolbar/menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 186 +++++--- src/app/file_list.rs | 440 ++++++++++++------ src/app/mod.rs | 20 +- src/app/outline.rs | 258 ++++++++-- src/app/project.rs | 33 +- src/app/spelling.rs | 42 +- src/app/story_bible_editor.rs | 17 +- src/app/ui.rs | 173 ++----- src/app/workspace.rs | 293 ++++++------ src/config.rs | 82 ++-- src/cookiecutter.rs | 411 ---------------- src/main.rs | 1 - src/preprocess.rs | 321 +++++++++---- .../05-First Draft/_template.md | 13 +- .../06-Revisions/line_edits/.gitkeep | 0 .../06-Revisions/proofreading/.gitkeep | 0 .../06-Revisions/structural_edits/notes.md | 0 .../07-Final/final_manuscript.md | 0 .../07-Final/metadata/.gitkeep | 0 .../07-Final/submission_materials/.gitkeep | 0 ...ookiecutter.project_name}} - Full Text.odm | Bin 0 -> 2568 bytes .../08-Covers/.gitkeep | 0 .../09-Archive/.gitkeep | 0 23 files changed, 1199 insertions(+), 1091 deletions(-) delete mode 100644 src/cookiecutter.rs create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/line_edits/.gitkeep create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/proofreading/.gitkeep create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/structural_edits/notes.md create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/07-Final/final_manuscript.md create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/07-Final/metadata/.gitkeep create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/07-Final/submission_materials/.gitkeep create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/07-Final/{{cookiecutter.project_name}} - Full Text.odm create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/08-Covers/.gitkeep create mode 100644 templates/snowflake/{{ cookiecutter.project_name }}/09-Archive/.gitkeep diff --git a/README.md b/README.md index 32ec094..0e123ab 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ as an ordered manuscript. chapter heading before each file's content — or, for a screenplay project, as a paginated Fountain-to-PDF script (see [Screenplays](#screenplays-fountain)). -* **Start a new project** from a cookiecutter template (**File ▸ New project…**), +* **Start a new project** from a bundled template (**File ▸ New project…**), which builds the whole folder structure and opens its drafting folder. Built in Rust with [`egui`](https://github.com/emilk/egui)/`eframe`. The ODT @@ -43,8 +43,17 @@ sudo apt install libgl1 libxkbcommon0 libwayland-client0 libx11-6 ```sh install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript +# The project templates are read from disk, so they travel with the binary: +cp -r templates ~/.local/bin/templates ``` +The app looks for `templates/snowflake/cookiecutter.json` beside the executable +and then up through its parent folders, so a release build run from the source +tree finds it without installing anything. Without it the app still starts and +everything else works — only **File ▸ New project…** has nothing to scaffold +from, and says so. You can also point **Settings ▸ New project… ▸ Template** at +a copy anywhere else. + Optionally add a desktop launcher at `~/.local/share/applications/md-manuscript.desktop`: @@ -184,11 +193,9 @@ target is stripped from the exported document. ### YAML frontmatter for planning metadata -For projects using the **snowflake template** or similar structured workflows, files -can store planning metadata in **YAML frontmatter** at the top of the file. This keeps -all planning information (target word count, POV, characters, setting, etc.) together with -the draft content in a single file, while allowing the metadata to be collapsed in the -editor. +A scene keeps what it knows about itself in **YAML frontmatter** at the top of +the file, and its prose below. This is the form **+ New Scene** writes and the +form the snowflake template ships: ```markdown --- @@ -197,31 +204,57 @@ slug: chapter-one-the-beginning act: 1 chapter: 1 scene: 1 -type: chapter status: draft -word_count_target: 2000 -word_count_min: 1800 -word_count_max: 2200 pov: "Bixby" +characters: [Bixby, Mom] +setting: "The Old Reynold's House" +date: "" +time: "" +conflict: "" +theme: "" +tone: "" +word_count_target: 1800-2200 +--- + +The prose begins here. +``` + +**Everything below the closing `---` is prose**, and nothing else: that is what +the status bar counts, what the progress bars measure, and what the exporter +writes into the `.odt`. Restating the header underneath it — a metadata table, a +POV section, a word-count tracker — puts all of that in the word count and in the +finished book, so the header is written once, above the line. + +Fields the app reads: **`title`** (the chapter heading), **`slug`** (its +caption, and the file-list tooltip), **`pov`** (also the tooltip), +**`status`** (the badge and filter in the file panel), **`characters`** (which +scenes a character appears in, in the Characters window) and +**`word_count_target`** (the progress bars). A `word_count_min`/`word_count_max` +pair works too, and wins over `word_count_target` when both are given. The rest — +`act`, `chapter`, `scene`, `setting`, `date`, `time`, `conflict`, `theme`, +`tone` — the app carries but does not act on; they are yours, and anything else +you add is kept the same way. + +Lists take either YAML form — `characters: [Bixby, Mom]` or + +```yaml characters: - Bixby - Mom -setting: "The Old Reynold's House" ---- - -# Chapter One: The Beginning - -The prose begins here... - -### Rough Draft: - -Actual draft content... ``` -The YAML frontmatter (delimited by `---` lines) is automatically detected and can be -collapsed in the editor using the **Collapse header** toggle in the top bar. The -draft marker (`### Rough Draft:`) is still supported for backward compatibility with -legacy files. +Values may be quoted or bare, an empty value reads as no value at all (an unset +`pov: ""` is not a POV), and `#` comments are ignored, so the template's notes +to itself cost nothing. + +The frontmatter can be folded away while you write with **View ▸ Hide header in +editor**, the same as a legacy header. + +**Legacy files still work.** A file with no frontmatter falls back to the older +form — `# Title:`, `# Slug:`, `# POV:` lines above a **`### Rough Draft:`** +marker, described under [Header stripping on export](#header-stripping-on-export). +The two are read through the same code, so a status filter or a character search +covers both. Frontmatter wins wherever a file has it. ## Project mode @@ -340,11 +373,14 @@ height, so folding them is worth real writing space and not just grey margin. ### Hiding the header while you write -A scene card's fields sit at the top of the same file as its prose, and once -drafting is under way they are mostly in the way. **View ▸ Hide header in -editor** folds away everything above the draft marker, leaving the prose alone -in the editor. A single line above the editor says so — `▸ header hidden (8 -fields)` — and clicking it flips back. +A scene's fields sit at the top of the same file as its prose, and once drafting +is under way they are mostly in the way. **View ▸ Hide header in editor** folds +them away, leaving the prose alone in the editor. A single line above the editor +says so — `▸ header hidden (15 fields)` — and clicking it flips back. + +**Either kind of header folds**: a YAML frontmatter block, delimiters and all, or +a legacy block above the draft marker. That line is the quickest way to do it — +click `▾ header shown` to fold, click `▸ header hidden` to bring it back. While the header is hidden it is genuinely **not in the editor's text**: the file is put back together on save, so what lands on disk is unchanged. Doing it @@ -353,7 +389,8 @@ spelling underlines and the caret all address exactly what you can see, with no offsets to go wrong. Everything that wants the whole file — the chapter title hint, the word-count target, the file-list tooltip — still reads it whole. -Files with no draft marker have nothing to fold, so the line does not appear. +A file with neither frontmatter nor a draft marker has nothing to fold, so the +line does not appear. ### Characters (Tools ▸ Characters…) @@ -608,8 +645,8 @@ single finished document, use **Export ODT**. ## New projects from templates **File ▸ ✨ New project…** scaffolds a whole manuscript project from a -template and then opens its drafting folder as the workspace, so you go from -nothing to editing chapters in one step. +template and then opens it as the workspace, so you go from nothing to editing +chapters in one step. The app now uses **built-in MiniJinja templating** (no external Python cookiecutter required) to render templates. A default "snowflake" template is @@ -620,7 +657,7 @@ The dialog asks for a **name**, **author**, **description** and the **folder to create it in** (prefilled with wherever you put the last one). Those are passed to the template as the `project_name`, `author` and `description` variables. Nothing is written until you press **Create**; generation runs in the background, -so a template whose hooks talk to the network doesn't freeze the editor. +so a large template doesn't freeze the editor. When it finishes, the app opens the project root as the workspace, with the **`05-First Draft`** folder (where the snowflake template keeps the @@ -642,8 +679,8 @@ rendering. | Setting | Meaning | |---|---| -| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to the bundled `templates/snowflake`; falls back to `~/Documents/Cookiecutters/snowflake` | -| **Open subfolder** | which folder of the generated project becomes the workspace (default `05-First Draft`; blank opens the project root) | +| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to the bundled `templates/snowflake`, found beside the executable or up through its parent folders | +| **Manuscript folder** | which folder of a project holds the book (default `05-First Draft`). Its files are ordered, numbered and exported; the rest of the project is reference. A leading number is optional, so `First Draft` also matches `05-First Draft`. A generated project always opens at its root — this does not change which folder the workspace is | | **Publish to Gitea after creation** | when enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it | | **Gitea** | server URL, user and token, used to create repositories when publishing is enabled | @@ -664,18 +701,16 @@ the project is still generated locally. The token is stored in `config.json` in plain text, the same as the Mistral API key. -This path runs entirely inside the template's own hook, so it applies to -projects made through **New project** and nothing else, and it does nothing if -**Run the template's hooks** is off, if any of the three Gitea settings is -blank, or if the template has no such hook. When it fails it fails in someone -else's Python, and all this app can report is that a hook exited non-zero — the -Git log window carries whatever the hook printed. +This runs in the app itself — it used to be a Python hook inside the template, +and is not one any more — so it applies to projects made through **New project** +and nothing else, and it does nothing if the checkbox is off or any of the three +Gitea settings is blank. Whatever the server said is reported in the Git log +window. -**Settings ▸ Git remote… does not go through the template at all.** It calls the -Gitea API directly, works on any workspace, and reports what the server actually -said (a rejected token, a URL that is not a Gitea API, a name the server would -not take). If the hook is not publishing and you would rather not debug it, that -is the way round. +**Settings ▸ Git remote… is the same thing for a workspace you already have.** +It calls the Gitea API directly, works on any folder rather than only a freshly +generated one, and reports what the server actually said (a rejected token, a +URL that is not a Gitea API, a name the server would not take). ## New files from a template @@ -685,23 +720,26 @@ The file list has two create buttons: |---|---|---| | **+ New** | the name you type beside it, optionally with folders | `# ` and a blank line | | **+ New from template** | auto-assigned `untitled-N.md` | the configured template | +| **+ New Scene** | `scene-NN.md` in the current act | a scene's frontmatter, described under [YAML frontmatter](#yaml-frontmatter-for-planning-metadata) | **+ New from template** takes no typed name — it picks the first free `untitled-N.md` (reusing a gap if you have deleted one) in the folder of the file you are currently editing, seeds it from the template, adds it to the manuscript order and selects it. Rename it later with -the **Rename** box, or set a `# Title:` header and let the chapter title come -from that. +the **Rename** box, or fill in `title:` and let the chapter title come from that. -Edit the template under **Settings ▸ New-file template…**. The built-in default is: +Edit the template under **Settings ▸ New-file template…**. The built-in default +is frontmatter, the same shape a scene uses: ``` -# Title: {{name}} -# Slug: -# POV: -# Word Count Target: - -### Rough Draft: +--- +title: {{name}} +slug: +status: draft +pov: +characters: [] +word_count_target: +--- ``` @@ -710,12 +748,13 @@ Three placeholders are expanded when the file is created: | Placeholder | Expands to | |---|---| | `{{name}}` | the file stem, e.g. `untitled-3` | -| `{{marker}}` | the draft marker set in the top bar (default `### Rough Draft:`) | +| `{{marker}}` | the draft marker set in the top bar (empty by default) | | `{{date}}` | today's date in UTC, as `YYYY-MM-DD` | -Anything else in double braces is left alone. Using `{{marker}}` rather than -typing the marker literally keeps the template working if you change the marker -later. Emptying the box restores the built-in default, and **Reset to default** +Anything else in double braces is left alone. `{{marker}}` is there for a +legacy-style template — using it rather than typing `### Rough Draft:` literally +keeps such a template working if you change the marker later; a frontmatter +template has no use for it. Emptying the box restores the built-in default, and **Reset to default** does the same in one click. The template is stored in `~/.config/md-manuscript/config.json`, so it is shared @@ -901,8 +940,11 @@ Manuscript files often carry editorial notes and metadata above the prose that should not appear in the finished document. On export, each file is cleaned up: * **HTML comments** (``, including multi-line ones) are removed. -* **The header block** — everything above the **Draft marker** line (set in the - top bar; default `### Rough Draft:`) — is dropped. From that header: +* **The frontmatter**, when the file has it — everything down to the closing + `---`. Otherwise **the header block**: everything above the **Draft marker** + line set in the top bar. That marker is empty by default, since a new file is + written as frontmatter; set it to `### Rough Draft:` for files in the older + form. From either header: * `# Title:` becomes the chapter heading (unless a manual override is set), and * `# Slug:` becomes an italic caption printed beneath the heading. @@ -970,8 +1012,24 @@ from the menu bar. the same heading are grouped together under collapsible headers with progress counts, making it easy to navigate large outlines and track progress at every level of your story structure. -- **Linked scene/chapter templates**: New file templates now support linked - planning+draft file pairs. Creating a scene in the draft folder automatically - creates a corresponding planning file in the Plot folder with references. +- **A scene says each thing once**: planning metadata lives in the YAML + frontmatter and nowhere else. **+ New Scene** used to write 28 keys and then + restate nearly all of them below the closing `---` as a metadata table, a + word-count tracker, POV/characters/setting/conflict/theme/tone sections, a + notes block and a checklist — all of it body text, so all of it counted toward + the word count and was exported into the `.odt`. A new scene now starts at the + prose. Nine keys the app could not keep true (`word_count`, `session_word_count`, + `total_sessions`, `version`, `created`, `updated`, …) are gone with it, along + with `id` and `order`, which restated the file's own path and `order.json`. +- **The header fold works on frontmatter**: the `▾ header shown` line above the + editor asked only whether the file had a draft marker, so it never appeared on + a frontmatter file — the one form the app writes. Both forms now offer it. +- **Frontmatter is read properly**: the status column, the Characters window and + the scene card now read frontmatter through the same parser as a legacy + `## POV:` header. `characters:` works in either YAML list form (it matched + nothing before, or one character called `[]`), keys with underscores are no + longer invisible, and prose below the frontmatter is no longer mistaken for + metadata — a line of dialogue like `Bixby: run` used to register as a field. + A `word_count_min`/`word_count_max` pair now sets the target, as documented. - **Automatic workspace refresh**: File list now automatically refreshes when files change on disk using a file watcher. diff --git a/src/app/file_list.rs b/src/app/file_list.rs index 1879113..733c218 100644 --- a/src/app/file_list.rs +++ b/src/app/file_list.rs @@ -166,11 +166,11 @@ impl App { let show_files = self.config.show_file_panel; egui::SidePanel::left("files") .resizable(true) + // `default_width` widens `width_range` to include itself, so it has + // to come before the bounds rather than after them. .default_width(260.0) - // A hard ceiling: the panel is sized from its content, so any row - // that asks for more width than there is would otherwise push it - // wider every frame. - .width_range(160.0..=460.0) + .min_width(160.0) + .max_width(460.0) .show_animated(ctx, show_files, |ui| { // The default theme renders unselected list rows fairly dim; bump // the widget text colours so file names stay legible (especially in @@ -193,13 +193,25 @@ impl App { } let statuses = self.known_statuses(); if !statuses.is_empty() { + // Deliberately not `horizontal_wrapped`: in a wrapping row + // `available_width` reports the whole line, because the next + // widget may start a new one — and sizing the box from that + // is how it ended up wider than the space left beside the + // label. ui.horizontal(|ui| { ui.label(egui::RichText::new("Status:").small().weak()); let current = self .status_filter .clone() .unwrap_or_else(|| "all".to_string()); + // Bounded and truncating: left to itself a `ComboBox` is + // as wide as its longest status name, and anything wider + // than the panel widens the panel (see `left_pane`'s note + // on why nothing here may overflow). + let combo_w = (ui.available_width() - 4.0).clamp(48.0, 140.0); egui::ComboBox::from_id_salt("status_filter") + .width(combo_w) + .truncate() .selected_text(egui::RichText::new(current).small()) .show_ui(ui, |ui| { if ui @@ -222,6 +234,96 @@ impl App { } ui.separator(); + // The actions live in their own panel, claimed *before* the + // list: that hands the list whatever height is left over + // instead of guessing at it, and keeps the buttons on screen in + // a short window. + egui::TopBottomPanel::bottom("file_actions") + .frame(egui::Frame::none().inner_margin(egui::Margin::symmetric(0.0, 4.0))) + .show_inside(ui, |ui| { + // Full-width edits and wrapping button rows, so this + // block fits whatever width the panel is dragged to. + // The old fixed 150pt edit beside two buttons needed + // ~330pt, which — a panel being sized from its content — + // *was* the panel's real minimum width. + ui.add( + egui::TextEdit::singleline(&mut self.new_name) + .hint_text("new file name") + .desired_width(f32::INFINITY), + ) + .on_hover_text( + "A name, or a path to nest it: part-1/ch-01 creates the \ + folder along with the file.", + ); + ui.horizontal_wrapped(|ui| { + if ui.button("+ New").clicked() { + self.create_file(); + } + if ui + .button("+ New Scene") + .on_hover_text( + "Create a new scene file with YAML frontmatter in 05-First Draft" + ) + .clicked() + { + self.create_scene_pair(); + } + }); + if ui + .add(egui::Button::new("+ New from template").truncate()) + .on_hover_text( + "Create untitled-N.md seeded from the template \ + (Settings ▸ New-file template…), in the current file's folder", + ) + .clicked() + { + self.create_file_from_template(); + } + + if self.selected.is_some() { + ui.add( + egui::TextEdit::singleline(&mut self.rename_input) + .hint_text("rename") + .desired_width(f32::INFINITY), + ) + .on_hover_text( + "The file's path within the workspace — edit the folder \ + part to move it.", + ); + ui.horizontal_wrapped(|ui| { + if ui.button("Rename").clicked() { + self.rename_selected(); + } + if ui + .button("🗄 Archive") + .on_hover_text( + "Move this file into the archive folder and out of \ + the manuscript, keeping it on disk", + ) + .clicked() + { + self.archive_selected(); + } + if !self.pending_delete { + if ui.button("🗑 Delete").clicked() { + self.pending_delete = true; + } + } else { + ui.label("Delete file?"); + if ui + .button(egui::RichText::new("Yes").color(egui::Color32::RED)) + .clicked() + { + self.delete_selected(); + } + if ui.button("No").clicked() { + self.pending_delete = false; + } + } + }); + } + }); + let mut clicked: Option = None; let mut toggled: Option = None; let mut dropped: Option = None; @@ -248,18 +350,27 @@ impl App { .cloned() .collect(); let rows = build_rows(&visible, &self.collapsed); - // Measured once, from the panel rather than from the scrolled - // content: reading `available_width()` inside a row makes the - // content's width depend on the content's width. - let row_width = ui.available_width(); let nested = rows.iter().any(|r| r.depth > 0); + // The width every row is laid out against, and which no row may + // exceed. A `ScrollArea` that cannot scroll sideways grows to + // fit its content, and a `SidePanel` is sized from its content + // in turn, so a single over-wide row widens the panel — and + // keeps it widened, because next frame the panel reloads that + // width as its own. That is what made the pane spring back from + // a resize. The deduction keeps the floating scroll bar from + // sitting on a row's last column. + let row_width = (ui.available_width() - SCROLLBAR_W).max(MIN_ROW_W); + let gap = ui.spacing().item_spacing.x; egui::ScrollArea::vertical() .auto_shrink([false, false]) - .max_height(ui.available_height() - 120.0) .show(ui, |ui| { for row in &rows { - let indent = row.depth as f32 * INDENT; + // Capped: past a point the indent would leave a + // deeply nested row nothing to draw its name into, + // and it would spill over the edge. + let indent = (row.depth as f32 * INDENT) + .min(row_width * MAX_INDENT_FRAC); match row.kind { RowKind::Folder { count, first } => { let open = !self.collapsed.contains(&row.path); @@ -283,14 +394,26 @@ impl App { } else { text.weak() }; - // Sized from the panel and truncated: - // an unconstrained label is as wide as - // its text, and the panel is sized from - // its content, so a long folder name - // would widen the panel and keep it - // widened. The full path is on hover. + // Sized from the row's budget and + // truncated: an unconstrained label is + // as wide as its text, which would push + // the row — and with it the panel — past + // the width the user chose. The full + // path is on hover. The tag goes first + // when there is no room for both. + let tag_w = gap + MANUSCRIPT_W; + let show_tag = is_manuscript_root + && row_width + - indent + - gap + - COUNT_W + - tag_w + >= MIN_LABEL_W; + let trailing = gap + + COUNT_W + + if show_tag { tag_w } else { 0.0 }; let name_w = - (row_width - indent - COUNT_W).max(48.0); + (row_width - indent - trailing).max(0.0); let button_response = ui .add_sized( [name_w, 20.0], @@ -313,17 +436,28 @@ impl App { } }); } - ui.label( - egui::RichText::new(count.to_string()) - .small() - .weak(), - ); - if is_manuscript_root { - ui.label( - egui::RichText::new("· manuscript") + // Fixed widths, so what the row + // actually consumes is what `name_w` + // was budgeted against. + ui.add_sized( + [COUNT_W, 20.0], + egui::Label::new( + egui::RichText::new(count.to_string()) .small() .weak(), ) + .truncate(), + ); + if show_tag { + ui.add_sized( + [MANUSCRIPT_W, 20.0], + egui::Label::new( + egui::RichText::new("· manuscript") + .small() + .weak(), + ) + .truncate(), + ) .on_hover_text( "These files are the book: ordered, \ numbered and exported. Everything \ @@ -384,33 +518,62 @@ impl App { egui::Id::new(("dnd", &name)), idx, |ui| { - ui.label( - egui::RichText::new("⠿") - .monospace() - .weak(), + // Sized, not free-standing: the + // budget below counts on the + // handle being exactly this wide. + ui.add_sized( + [HANDLE_W, 20.0], + egui::Label::new( + egui::RichText::new("⠿") + .monospace() + .weak(), + ), ); }, ); - // Reserve room on the right for a per-file - // word-count-target bar when the file sets one. - let bar_w = 44.0; - let reserve = if meta.goal.is_some() { - bar_w + 6.0 - } else { - 0.0 - }; - let label_w = (row_width - - indent - - HANDLE_W - - reserve) - .max(24.0); + // Everything the row draws has to come + // out of `row_width`: the handle, the + // gap after each item, and whatever + // trailing badges the file has earned. + // A badge is dropped rather than let the + // name shrink below `MIN_LABEL_W` — a row + // that overflows would widen the panel. + let mut trailing = 0.0; + let head = row_width - indent - HANDLE_W - gap; + let show_bar = meta.goal.is_some() + && head - (gap + BAR_W) >= MIN_LABEL_W; + if show_bar { + trailing += gap + BAR_W; + } + let show_status = meta.status.is_some() + && head - trailing - (gap + STATUS_W) + >= MIN_LABEL_W; + if show_status { + trailing += gap + STATUS_W; + } + let label_w = (head - trailing).max(0.0); let display_name = truncate_display_name(base_name(&name)); let text = egui::RichText::new(display_name); let text = if in_book { text } else { text.weak() }; - let mut label = ui.add_sized( - [label_w, 20.0], - egui::SelectableLabel::new(selected, text), - ); + // `SelectableLabel` has no truncate + // of its own and lays its text out + // with `Extend`, so `add_sized` does + // not hold it to `label_w` — a long + // file name simply spills out of the + // panel. The wrap mode is what it + // actually reads. + let mut label = ui + .scope(|ui| { + ui.style_mut().wrap_mode = + Some(egui::TextWrapMode::Truncate); + ui.add_sized( + [label_w, 20.0], + egui::SelectableLabel::new( + selected, text, + ), + ) + }) + .inner; if let Some(tooltip) = &tooltip { label = label.on_hover_text(tooltip); } @@ -426,20 +589,32 @@ impl App { ); }); if let Some(goal) = meta.goal { - row_goal_bar( - ui, - bar_w, - goal, - meta.prose_words, - ); + if show_bar { + row_goal_bar( + ui, + BAR_W, + goal, + meta.prose_words, + ); + } } if let Some(status) = &meta.status { - ui.label( - egui::RichText::new(status_tag(status)) - .small() - .weak(), - ) - .on_hover_text(format!("Status: {status}")); + if show_status { + ui.add_sized( + [STATUS_W, 20.0], + egui::Label::new( + egui::RichText::new(status_tag( + status, + )) + .small() + .weak(), + ) + .truncate(), + ) + .on_hover_text(format!( + "Status: {status}" + )); + } } }) .response; @@ -483,14 +658,25 @@ impl App { // to get a file back out to the top level. if nested { ui.add_space(2.0); + // Laid out in a row of the budgeted width, so this + // hint cannot be the thing that widens the panel. let target = ui - .horizontal(|ui| { - ui.label( - egui::RichText::new("↥ drop here for the top level") - .small() - .weak(), - ); - }) + .allocate_ui_with_layout( + egui::vec2(row_width, 18.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.add( + egui::Label::new( + egui::RichText::new( + "↥ drop here for the top level", + ) + .small() + .weak(), + ) + .truncate(), + ); + }, + ) .response; let target = full_width_row(ui, &target, "\u{0}root-drop"); if drop_highlight(ui, &target) { @@ -525,87 +711,6 @@ impl App { self.open_outline_file = Some(outline_path); } - ui.separator(); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut self.new_name) - .hint_text("new file name") - .desired_width(150.0), - ) - .on_hover_text( - "A name, or a path to nest it: part-1/ch-01 creates the \ - folder along with the file.", - ); - if ui.button("+ New").clicked() { - self.create_file(); - } - if ui - .button("+ New Scene") - .on_hover_text( - "Create a new scene file with YAML frontmatter in 05-First Draft" - ) - .clicked() - { - self.create_scene_pair(); - } - }); - if ui - .button("+ New from template") - .on_hover_text( - "Create untitled-N.md seeded from the template \ - (Settings ▸ New-file template…), in the current file's folder", - ) - .clicked() - { - self.create_file_from_template(); - } - - if self.selected.is_some() { - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut self.rename_input) - .hint_text("rename") - .desired_width(150.0), - ) - .on_hover_text( - "The file's path within the workspace — edit the folder \ - part to move it.", - ); - if ui.button("Rename").clicked() { - self.rename_selected(); - } - }); - ui.horizontal(|ui| { - if ui - .button("🗄 Archive") - .on_hover_text( - "Move this file into the archive folder and out of \ - the manuscript, keeping it on disk", - ) - .clicked() - { - self.archive_selected(); - } - }); - ui.horizontal(|ui| { - if !self.pending_delete { - if ui.button("🗑 Delete").clicked() { - self.pending_delete = true; - } - } else { - ui.label("Delete file?"); - if ui - .button(egui::RichText::new("Yes").color(egui::Color32::RED)) - .clicked() - { - self.delete_selected(); - } - if ui.button("No").clicked() { - self.pending_delete = false; - } - } - }); - } }); } } @@ -694,12 +799,45 @@ fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool { -/// Width the drag handle occupies in a file row, so a row's label can be sized -/// from the panel width rather than from whatever is left of the content. -const HANDLE_W: f32 = 22.0; +/// The widths a row's parts are pinned to. Every one of them is *allocated*, +/// not merely assumed: a row is laid out against a budget (`row_width`), and a +/// row that spends more than its budget makes the file list wider than the +/// panel — which, because a `SidePanel` takes its width from its content, makes +/// the panel itself wider and undoes whatever width the user dragged it to. +/// Anything drawn in a row therefore gets a size, and the budget subtracts +/// exactly that size plus the item spacing that follows it. +/// +/// Width the drag handle occupies in a file row. +const HANDLE_W: f32 = 16.0; -/// Room left after a folder's name for its file count and the manuscript tag. -const COUNT_W: f32 = 86.0; +/// Room after a folder's name for the count of files under it. +const COUNT_W: f32 = 22.0; + +/// Room after that count for the "· manuscript" tag on the book's own folder. +const MANUSCRIPT_W: f32 = 58.0; + +/// Room at the end of a file row for its status badge. +const STATUS_W: f32 = 26.0; + +/// Width of a file row's word-count-target bar. +const BAR_W: f32 = 44.0; + +/// Kept clear on the right of the list: egui's scroll bar floats over the +/// content rather than reserving space for itself, and would otherwise sit on +/// top of a row's last column. +const SCROLLBAR_W: f32 = 10.0; + +/// How much of a row a name wants before the trailing badges are worth their +/// space. Below it a badge is dropped instead — it decides what a row shows, +/// never how wide a row is. +const MIN_LABEL_W: f32 = 56.0; + +/// A row never gives up more than this share of its width to indentation, so a +/// deeply nested file still has a name to click at a narrow panel width. +const MAX_INDENT_FRAC: f32 = 0.4; + +/// The narrowest budget a row is ever laid out against, whatever the panel does. +const MIN_ROW_W: f32 = 80.0; /// A response covering the whole visible width of `ui` at the row's height. /// diff --git a/src/app/mod.rs b/src/app/mod.rs index 6da5623..11d278e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -389,6 +389,9 @@ pub struct App { story_bible_editor_content: String, /// The workspace-relative path of the file being edited in the Story Bible editor. story_bible_editor_path: Option, + /// Whether the caret was in the Story Bible editor's text area last frame, + /// which is what decides where `Ctrl+S` sends its save. + story_bible_editor_focused: bool, /// List of auto-snapshots for rollback. snapshots: Vec, /// Word count at the last auto-snapshot. @@ -502,6 +505,7 @@ impl App { show_story_bible_editor: false, story_bible_editor_content: String::new(), story_bible_editor_path: None, + story_bible_editor_focused: false, snapshots: Vec::new(), last_snapshot_word_count: 0, show_snapshots: false, @@ -514,10 +518,20 @@ impl App { impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Ctrl+S saves. + // Ctrl+S saves whatever is being typed in. Usually that is the open + // manuscript file, but the Story Bible editor is a second text area with + // its own file, and sending its keystroke to the manuscript both loses + // the edit and writes a file the user was not looking at. if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) { - self.dirty = true; - self.save_current(); + match self.story_bible_editor_path.clone() { + Some(path) if self.show_story_bible_editor && self.story_bible_editor_focused => { + self.save_story_bible_item(path); + } + _ => { + self.dirty = true; + self.save_current(); + } + } } // Ctrl+D folds the panels away for distraction-free writing, and back. diff --git a/src/app/outline.rs b/src/app/outline.rs index c298b2d..faa24e2 100644 --- a/src/app/outline.rs +++ b/src/app/outline.rs @@ -10,73 +10,76 @@ pub(super) struct OutlineFile { } /// A tree node for rendering nested beats in the outline. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] enum BeatTree { - /// A heading node with children (other headings or beats) - Heading { name: String, depth: usize, children: Vec }, + /// A heading, holding the beats written under it and any headings nested + /// inside it. + Heading { name: String, children: Vec }, /// A beat leaf node Beat { name: String, filled: bool }, } -/// Build a tree structure from a flat list of beats based on depth and path. +/// Build the tree the Outline window draws out of the flat list of beats. +/// +/// Every beat carries the chain of headings it sits under ([`Beat::path`]), so +/// the tree is that chain turned inside out: a heading is opened the first time +/// a beat names it and stays open while the beats that follow keep naming it. +/// Going by `section` — the innermost heading on its own — is what used to give +/// every single beat a heading of its own, so `Act 1` with three beats under it +/// drew as three `Act 1 (0/1)` headers rather than one `Act 1 (0/3)`. fn build_beat_tree(beats: &[crate::outline::Beat]) -> Vec { let mut tree: Vec = Vec::new(); - - // For now, just create a simple flat list with section headings - // The full nested implementation requires more complex tree building - let mut current_section: Option = None; - let mut current_children: Vec = Vec::new(); - for beat in beats { - if !beat.section.is_empty() { - // New section - push current section if it exists - if let Some(old_section) = current_section.take() { - tree.push(BeatTree::Heading { - name: old_section, - depth: 1, - children: std::mem::take(&mut current_children), - }); - } - current_section = Some(beat.section.clone()); - current_children.push(BeatTree::Beat { - name: beat.name.clone(), - filled: beat.filled, - }); - } else { - current_children.push(BeatTree::Beat { - name: beat.name.clone(), - filled: beat.filled, - }); - } + insert_beat(&mut tree, &beat.path, beat); } - - // Push the last section if it exists - if let Some(section) = current_section.take() { - tree.push(BeatTree::Heading { - name: section, - depth: 1, - children: current_children, - }); - } else { - tree.append(&mut current_children); - } - tree } +/// Put one beat at the end of `level`, opening whichever headings in `path` are +/// not open there already. +fn insert_beat(level: &mut Vec, path: &[String], beat: &crate::outline::Beat) { + let Some((heading, rest)) = path.split_first() else { + level.push(BeatTree::Beat { + name: beat.name.clone(), + filled: beat.filled, + }); + return; + }; + // Only the last node is a candidate: a heading that comes back after a + // different one has intervened is a second section of the outline, and the + // window should show it where the file puts it rather than merging the two. + let reusable = matches!( + level.last(), + Some(BeatTree::Heading { name, .. }) if name == heading + ); + if !reusable { + level.push(BeatTree::Heading { + name: heading.clone(), + children: Vec::new(), + }); + } + if let Some(BeatTree::Heading { children, .. }) = level.last_mut() { + insert_beat(children, rest, beat); + } +} + /// Render a beat tree recursively. -fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree]) { - for node in tree { +/// +/// `salt` is the path taken to get here. Two sections can carry the same name at +/// different points in a file — and at different depths — so a heading's id has +/// to come from its position in the tree, not from its text. +fn render_beat_tree(ui: &mut egui::Ui, tree: &[BeatTree], salt: &str) { + for (i, node) in tree.iter().enumerate() { match node { - BeatTree::Heading { name, children, .. } => { + BeatTree::Heading { name, children } => { let (done, total) = count_beat_tree(children); let header_text = format!("{name} ({done}/{total})"); - + let salt = format!("{salt}/{i}:{name}"); egui::CollapsingHeader::new(header_text) - .id_salt(name) + .id_salt(&salt) .default_open(true) .show(ui, |ui| { - render_beat_tree(ui, children); + render_beat_tree(ui, children, &salt); }); } BeatTree::Beat { name, filled } => { @@ -220,7 +223,7 @@ impl App { if ui.link("open this file").clicked() { open_file = Some(file.rel.clone()); } - render_beat_tree(ui, &tree); + render_beat_tree(ui, &tree, &file.rel); }); } }); @@ -240,3 +243,160 @@ impl App { self.show_outline = open && !close; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::outline::Beat; + + fn beat(path: &[&str], name: &str, filled: bool) -> Beat { + Beat { + section: path.last().map(|s| s.to_string()).unwrap_or_default(), + name: name.to_string(), + filled, + depth: path.len(), + path: path.iter().map(|s| s.to_string()).collect(), + } + } + + /// Render the tree as `Heading(done/total)` / `beat` lines, indented, which + /// is compact enough to assert a whole outline's shape at once. + fn sketch(tree: &[BeatTree], depth: usize) -> Vec { + let pad = " ".repeat(depth); + let mut out = Vec::new(); + for node in tree { + match node { + BeatTree::Heading { name, children } => { + let (done, total) = count_beat_tree(children); + out.push(format!("{pad}{name}({done}/{total})")); + out.extend(sketch(children, depth + 1)); + } + BeatTree::Beat { name, filled } => { + out.push(format!("{pad}{}{name}", if *filled { "x " } else { " " })); + } + } + } + out + } + + #[test] + fn beats_under_one_heading_share_it() { + // The bug this replaces opened a fresh heading per beat, so three beats + // under `Act 1` drew as three `Act 1 (0/1)` headers. + let beats = [ + beat(&["Act 1"], "Opening", true), + beat(&["Act 1"], "Inciting Incident", false), + beat(&["Act 1"], "Plot Point 1", false), + beat(&["Act 2"], "Midpoint", true), + beat(&["Act 2"], "Plot Point 2", false), + ]; + assert_eq!( + sketch(&build_beat_tree(&beats), 0), + vec![ + "Act 1(1/3)", + " x Opening", + " Inciting Incident", + " Plot Point 1", + "Act 2(1/2)", + " x Midpoint", + " Plot Point 2", + ] + ); + } + + #[test] + fn headings_nest_and_count_everything_below_them() { + // `#`/`##`/`###` give a beat a path, and a parent's tally is the sum of + // its children's rather than only the beats directly under it. + let beats = [ + beat(&["Act 1"], "Opening", true), + beat(&["Act 1", "Chapter 1"], "She arrives", true), + beat(&["Act 1", "Chapter 1"], "She knocks", false), + beat(&["Act 1", "Chapter 2"], "Nothing", false), + ]; + assert_eq!( + sketch(&build_beat_tree(&beats), 0), + vec![ + "Act 1(2/4)", + " x Opening", + " Chapter 1(1/2)", + " x She arrives", + " She knocks", + " Chapter 2(0/1)", + " Nothing", + ] + ); + } + + #[test] + fn a_beat_with_no_heading_sits_at_the_top_level() { + let beats = [ + beat(&[], "Loose beat", false), + beat(&["Act 1"], "Opening", true), + ]; + assert_eq!( + sketch(&build_beat_tree(&beats), 0), + vec![" Loose beat", "Act 1(1/1)", " x Opening"] + ); + } + + #[test] + fn a_heading_that_comes_back_later_opens_again() { + // Two `Notes` sections either side of `Act 1` are two places in the + // file; merging them would put beats somewhere the outline does not. + let beats = [ + beat(&["Notes"], "First", false), + beat(&["Act 1"], "Opening", true), + beat(&["Notes"], "Second", false), + ]; + assert_eq!( + sketch(&build_beat_tree(&beats), 0), + vec![ + "Notes(0/1)", + " First", + "Act 1(1/1)", + " x Opening", + "Notes(0/1)", + " Second", + ] + ); + } + + #[test] + fn an_outline_with_no_beats_draws_nothing() { + assert!(build_beat_tree(&[]).is_empty()); + } + + #[test] + fn a_real_outline_file_groups_under_its_headings() { + // End to end over the two modules, in the shape the snowflake template + // actually writes: one `##` section per act file, prompts beneath it. + let markdown = "\ +## Act I – Setup + + + +She wakes before the alarm and lies there listening. + + + + + +### Sequence 2 + + +"; + let beats = crate::outline::beats(markdown); + assert_eq!( + sketch(&build_beat_tree(&beats), 0), + vec![ + "Act I – Setup(1/4)", + " x Opening Image", + " Inciting Incident", + " Lock-In", + " Sequence 2(0/1)", + " Debate", + ] + ); + } +} diff --git a/src/app/project.rs b/src/app/project.rs index 7ebfecd..46351b4 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -433,6 +433,26 @@ impl App { .weak(), ); ui.separator(); + ui.horizontal(|ui| { + ui.label("Draft marker:").on_hover_text( + "Text above this line in each file (its header) is dropped on \ + export; # Title: / # Slug: lines become the chapter heading \ + and caption. Leave blank to export files whole.", + ); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.draft_marker) + .desired_width(140.0), + ); + save_now |= r.lost_focus(); + }); + let r = ui + .checkbox(&mut self.config.zero_pad_index, "Zero-pad chapter #") + .on_hover_text( + "When a chapter title defaults to its number, pad it with \ + leading zeros (e.g. 03. of 12).", + ); + save_now |= r.changed(); + ui.separator(); if ui.button("Close").clicked() { close = true; } @@ -514,15 +534,6 @@ impl App { ); save_now |= r.lost_focus(); ui.end_row(); - - ui.label("Template path:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.cookiecutter_bin) - .hint_text("no longer used - built-in MiniJinja") - .desired_width(280.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); }); ui.label( egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed") @@ -537,7 +548,9 @@ impl App { "Publish to Gitea after creation", ) .on_hover_text( - "When enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it.", + "When enabled and Gitea credentials are configured below, \ + a new repository will be created and the project pushed \ + to it.", ) .changed() { diff --git a/src/app/spelling.rs b/src/app/spelling.rs index 0fe0026..548ec8c 100644 --- a/src/app/spelling.rs +++ b/src/app/spelling.rs @@ -254,12 +254,52 @@ impl App { let mut remove: Option = None; let mut harvest = false; - egui::Window::new("Word list") + egui::Window::new("Spelling") .open(&mut open) .resizable(true) .collapsible(false) .default_width(360.0) .show(ctx, |ui| { + if ui + .checkbox(&mut self.config.spell_check, "Check as I type") + .on_hover_text( + "Underline misspellings live using the dictionary below. \ + Offline (Hunspell); right-click a word for suggestions.", + ) + .changed() + { + self.config.save(); + if self.config.spell_check { + // Re-check the current buffer straight away. + self.spell_dirty = true; + self.spell_last_edit = None; + } + } + ui.horizontal(|ui| { + ui.label("Dictionary:"); + let current = self.config.spell_language.clone(); + let mut pick: Option = None; + egui::ComboBox::from_id_salt("spell_dict") + .selected_text(current.clone()) + .show_ui(ui, |ui| { + for d in &self.spell_dicts { + if ui + .selectable_label(current == d.id, &d.label) + .clicked() + { + pick = Some(d.id.clone()); + } + } + }); + if let Some(id) = pick { + self.set_spell_language(&id); + } + }); + if !self.spell_status.is_empty() { + ui.label(egui::RichText::new(&self.spell_status).weak()); + } + ui.separator(); + ui.label( egui::RichText::new( "Names and invented terms the spell checker accepts. Saved in \ diff --git a/src/app/story_bible_editor.rs b/src/app/story_bible_editor.rs index 6ad19f1..40e0b1b 100644 --- a/src/app/story_bible_editor.rs +++ b/src/app/story_bible_editor.rs @@ -221,11 +221,15 @@ impl App { if !open { self.story_bible_editor_content.clear(); self.story_bible_editor_path = None; + self.story_bible_editor_focused = false; } } /// Contents of the Story Bible editor window. fn story_bible_editor_contents(&mut self, ui: &mut egui::Ui, tree: &[BibleTreeNode]) { + // Set by the editor's text area below, read back out once the closures + // that borrow `self` have finished. + let mut focused = false; ui.horizontal(|ui| { // Left panel: Tree view (1/3 width) let tree_width = ui.available_width() / 3.0; @@ -275,12 +279,12 @@ impl App { .desired_rows(20); let response = ui.add(text_edit); - - // Handle keyboard shortcuts - if response.has_focus() { - // Ctrl+S to save - we'll handle this differently - // since we can't mutate self directly from within the closure - } + + // Recorded rather than acted on: the save needs `&mut + // self`, which this closure is already borrowing to + // draw with. `App::update` reads the flag next frame and + // routes Ctrl+S here instead of to the manuscript. + focused = response.has_focus(); }); // Save button @@ -298,6 +302,7 @@ impl App { }); }); }); + self.story_bible_editor_focused = focused; } /// Load a Story Bible item from the workspace. diff --git a/src/app/ui.rs b/src/app/ui.rs index 070e87f..5f93c62 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -6,8 +6,14 @@ use super::*; impl App { // ---- UI ---------------------------------------------------------------- - /// The toolbar — the Workspace, Export, Grammar and Spelling rows — and the - /// status bar along the bottom, which is rendered here too. + /// The toolbar — the Workspace, Export and Grammar rows — and the status + /// bar along the bottom, which is rendered here too. + /// + /// Anything with a menu equivalent (opening the workspace, syncing git, + /// toggling a panel, LanguageTool settings, …) lives only in the menu bar; + /// this panel keeps just the controls that need a live value at hand — + /// the workspace/export paths and the grammar-check trigger — so hiding it + /// (View ▸ Toolbar, or Ctrl+D) never costs you a feature, only convenience. /// /// The toolbar collapses through `show_animated`, which reserves no height /// when hidden and slides rather than snapping. Note that it guards only the @@ -22,9 +28,6 @@ impl App { egui::TextEdit::singleline(&mut self.workspace_input) .desired_width(300.0), ); - if ui.button("📂").on_hover_text("Browse for workspace folder").clicked() { - self.browse_workspace(); - } if ui.button("Open").clicked() || (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { @@ -33,23 +36,6 @@ impl App { self.config.save(); self.open_workspace(); } - ui.separator(); - if self.is_repo { - let hover = match &self.repo_root { - Some(root) if root.as_path() != self.workspace() => { - format!("Commit & push to the repository at {}", root.display()) - } - _ => "Commit & push this workspace's repository".to_string(), - }; - if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() { - self.git_sync(); - } - } else if ui.button("Init git").clicked() { - self.git_init(); - } - if ui.button("Log").clicked() { - self.show_log = !self.show_log; - } }); ui.add_space(2.0); ui.horizontal(|ui| { @@ -78,48 +64,6 @@ impl App { { self.export_manuscript(); } - // Resolved first: the hover text borrows the path, and the click - // handler needs `self` mutably. - let project = self.project_root().display().to_string(); - if ui - .button("📁") - .on_hover_text(format!("Open the project folder {project}")) - .clicked() - { - self.open_project_folder(); - } - ui.separator(); - if ui - .checkbox(&mut self.config.show_preview, "Preview") - .changed() - { - self.config.save(); - } - ui.separator(); - ui.label("Draft marker:") - .on_hover_text( - "Text above this line in each file (its header) is dropped on export; \ - # Title: / # Slug: lines become the chapter heading and caption. \ - Leave blank to export files whole.", - ); - let marker_resp = ui.add( - egui::TextEdit::singleline(&mut self.config.draft_marker) - .desired_width(140.0), - ); - if marker_resp.lost_focus() { - self.config.save(); - } - ui.separator(); - if ui - .checkbox(&mut self.config.zero_pad_index, "Zero-pad #") - .on_hover_text( - "When a chapter title defaults to its number, pad it with \ - leading zeros (e.g. 03. of 12).", - ) - .changed() - { - self.config.save(); - } }); ui.add_space(2.0); ui.horizontal(|ui| { @@ -143,58 +87,10 @@ impl App { { self.clear_lt(); } - if ui - .button("⚙") - .on_hover_text("LanguageTool settings") - .clicked() - { - self.show_settings = true; - } if !self.lt_status.is_empty() { ui.label(egui::RichText::new(&self.lt_status).weak()); } }); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.label("Spelling:").on_hover_text( - "Offline spell check (Hunspell dictionaries). Underlines misspellings \ - as you type when LanguageTool results aren't current; right-click a \ - word for suggestions.", - ); - if ui - .checkbox(&mut self.config.spell_check, "Check as I type") - .on_hover_text("Underline misspellings live using the dictionary below") - .changed() - { - self.config.save(); - if self.config.spell_check { - // Re-check the current buffer straight away. - self.spell_dirty = true; - self.spell_last_edit = None; - } - } - ui.label("Dictionary:"); - let current = self.config.spell_language.clone(); - let mut pick: Option = None; - egui::ComboBox::from_id_salt("spell_dict") - .selected_text(current.clone()) - .show_ui(ui, |ui| { - for d in &self.spell_dicts { - if ui - .selectable_label(current == d.id, &d.label) - .clicked() - { - pick = Some(d.id.clone()); - } - } - }); - if let Some(id) = pick { - self.set_spell_language(&id); - } - if !self.spell_status.is_empty() { - ui.label(egui::RichText::new(&self.spell_status).weak()); - } - }); ui.add_space(4.0); }); @@ -271,7 +167,8 @@ impl App { if ui .button("✨ New project…") .on_hover_text( - "Scaffold a project from the cookiecutter template and open its drafting folder", + "Scaffold a project from the bundled template and open \ + its drafting folder", ) .clicked() { @@ -283,6 +180,26 @@ impl App { ui.close_menu(); self.browse_workspace(); } + if ui.button("📁 Open project folder").clicked() { + ui.close_menu(); + self.open_project_folder(); + } + if self.is_repo { + let hover = match &self.repo_root { + Some(root) if root.as_path() != self.workspace() => { + format!("Commit & push to the repository at {}", root.display()) + } + _ => "Commit & push this workspace's repository".to_string(), + }; + if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() { + ui.close_menu(); + self.git_sync(); + } + } else if ui.button("Init git").clicked() { + ui.close_menu(); + self.git_init(); + } + ui.separator(); if ui.button(self.export_label()).clicked() { ui.close_menu(); self.export_manuscript(); @@ -321,10 +238,6 @@ impl App { ui.close_menu(); self.open_find(true); } - if ui.button("📖 Story Bible…").clicked() { - ui.close_menu(); - self.open_story_bible_editor(); - } if ui.button("📸 Snapshots…").clicked() { ui.close_menu(); self.show_snapshots = true; @@ -343,6 +256,10 @@ impl App { ui.close_menu(); self.show_story_bible = true; } + if ui.button("📖 Story Bible editor…").clicked() { + ui.close_menu(); + self.open_story_bible_editor(); + } ui.separator(); let busy = self.beats_rx.is_some(); if ui @@ -360,7 +277,7 @@ impl App { if ui .checkbox(&mut self.config.show_toolbar, "Toolbar") .on_hover_text( - "The Workspace, Export, Grammar and Spelling rows \ + "The Workspace, Export and Grammar rows \ under the menu bar", ) .clicked() @@ -432,7 +349,7 @@ impl App { ui.close_menu(); self.show_template_settings = true; } - if ui.button("New project…").clicked() { + if ui.button("New-project settings…").clicked() { ui.close_menu(); self.show_project_settings = true; } @@ -444,7 +361,7 @@ impl App { ui.close_menu(); self.show_manuscript_settings = true; } - if ui.button("Word list…").clicked() { + if ui.button("Spelling…").clicked() { ui.close_menu(); self.show_wordlist = true; } @@ -459,9 +376,9 @@ impl App { // a control that hides the toolbar cannot live inside it. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (icon, hint) = if self.config.show_toolbar { - ("⬆", "Hide the toolbar (Workspace, Export, Grammar, Spelling)") + ("⬆", "Hide the toolbar (Workspace, Export, Grammar)") } else { - ("⬇", "Show the toolbar (Workspace, Export, Grammar, Spelling)") + ("⬇", "Show the toolbar (Workspace, Export, Grammar)") }; if ui.button(icon).on_hover_text(hint).clicked() { self.config.show_toolbar = !self.config.show_toolbar; @@ -666,11 +583,11 @@ impl App { pub(super) fn header_toggle(&mut self, ui: &mut egui::Ui) { let collapsed = self.header_stash.is_some(); - // Nothing to say about a file with no marker to fold at. - if !collapsed - && crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker) - .is_none() - { + // Nothing to say about a file with no header to fold away. Both forms + // count: asking only about the draft marker meant a frontmatter file + // never offered the toggle, because it has no marker to find — the fold + // itself has handled frontmatter all along. + if !collapsed && !self.header_is_foldable() { return; } ui.horizontal(|ui| { @@ -678,7 +595,7 @@ impl App { let n = self.hidden_field_count(); ( format!("▸ header hidden ({n} field{})", if n == 1 { "" } else { "s" }), - "Show the fields above the draft marker", + "Show this file's fields again", ) } else { ( diff --git a/src/app/workspace.rs b/src/app/workspace.rs index e101ca1..1957134 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -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 = 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 = 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); } } + diff --git a/src/config.rs b/src/config.rs index c3dd2ea..a0a62f5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 diff --git a/src/cookiecutter.rs b/src/cookiecutter.rs deleted file mode 100644 index 8b1adf9..0000000 --- a/src/cookiecutter.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Driving the `cookiecutter` CLI to scaffold a new manuscript project. -//! -//! Cookiecutter is a Python tool that renders a directory template through -//! Jinja, so this module shells out to it rather than reimplementing it. The -//! work here is finding the executable (it is usually installed in a conda or -//! per-user prefix that a desktop-launched app does not have on `PATH`), -//! building the non-interactive command line, and working out which directory -//! the run produced. - -use std::collections::HashSet; -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Name of the executable looked for when no explicit path is configured. -const BIN_NAME: &str = "cookiecutter"; - -/// Prefixes searched for the executable when `PATH` does not have it, in order. -/// Cookiecutter usually arrives via conda or `pip install --user`, neither of -/// which is on the `PATH` of a process started from a desktop launcher. -const FALLBACK_PREFIXES: &[&str] = &[ - ".anaconda3/bin", - "anaconda3/bin", - "miniconda3/bin", - "miniforge3/bin", - ".local/bin", -]; - -/// Absolute directories searched after the per-user prefixes. -const SYSTEM_DIRS: &[&str] = &["/usr/local/bin", "/usr/bin"]; - -/// One `key=value` override handed to cookiecutter. -pub struct Var { - pub key: String, - pub value: String, -} - -impl Var { - pub fn new(key: &str, value: &str) -> Self { - Var { - key: key.to_string(), - value: value.to_string(), - } - } -} - -/// Everything one generation run needs. -pub struct Request { - /// The `cookiecutter` executable, already resolved. - pub bin: PathBuf, - /// Directory holding the template's `cookiecutter.json`. - pub template: PathBuf, - /// Directory the new project is created inside. - pub output_dir: PathBuf, - /// Template variable overrides (`project_name=…`). - pub vars: Vec, - /// Environment given to the child, on top of the inherited one. Used for the - /// credentials a template's hooks read. - pub env: Vec<(String, String)>, - /// Whether to run the template's pre/post-generation hooks. - pub run_hooks: bool, -} - -/// Look for the `cookiecutter` executable. -/// -/// `configured` wins when it is set: a value containing a separator is taken as -/// a path, anything else as a program name to look up. With no configuration, -/// `PATH` is searched first, then the usual per-user Python prefixes. -pub fn resolve_binary(configured: &str, home: Option<&Path>) -> Option { - let configured = configured.trim(); - if !configured.is_empty() { - if configured.contains('/') { - let path = PathBuf::from(configured); - return is_program(&path).then_some(path); - } - return search_path(configured); - } - if let Some(found) = search_path(BIN_NAME) { - return Some(found); - } - let user_dirs = home.into_iter().flat_map(|home| { - FALLBACK_PREFIXES - .iter() - .map(move |prefix| home.join(prefix)) - }); - let system_dirs = SYSTEM_DIRS.iter().map(PathBuf::from); - user_dirs - .chain(system_dirs) - .map(|dir| dir.join(BIN_NAME)) - .find(|candidate| is_program(candidate)) -} - -/// Whether `path` is an existing file we could execute. -fn is_program(path: &Path) -> bool { - path.is_file() -} - -/// Find `name` in the `PATH` environment variable. -fn search_path(name: &str) -> Option { - let path = std::env::var_os("PATH")?; - std::env::split_paths(&path) - .map(|dir| dir.join(name)) - .find(|candidate| is_program(candidate)) -} - -/// Build the command line for a request: the flags, the template, then the -/// variable overrides. Kept separate from running it so the argument order can -/// be asserted in tests without invoking Python. -pub fn build_args(request: &Request) -> Vec { - let mut args: Vec = vec![ - // Every value comes from the dialog, so the child must never block on a - // prompt — there is no terminal attached to read it. - "--no-input".into(), - "--output-dir".into(), - request.output_dir.clone().into(), - "--accept-hooks".into(), - if request.run_hooks { "yes" } else { "no" }.into(), - request.template.clone().into(), - ]; - for var in &request.vars { - args.push(format!("{}={}", var.key, var.value).into()); - } - args -} - -/// Directory entry names directly inside `dir`, or an empty set if unreadable. -fn entries(dir: &Path) -> HashSet { - match std::fs::read_dir(dir) { - Ok(read) => read.flatten().map(|e| e.file_name()).collect(), - Err(_) => HashSet::new(), - } -} - -/// Run cookiecutter and return the directory it created. -/// -/// The new project is identified by diffing the output directory before and -/// after, which works whatever a template names its root — the rendered folder -/// name is a Jinja expression this app never sees. -pub fn generate(request: &Request) -> Result { - if !request.template.join("cookiecutter.json").is_file() { - return Err(format!( - "No cookiecutter.json in {} — that folder is not a template.", - request.template.display() - )); - } - std::fs::create_dir_all(&request.output_dir) - .map_err(|e| format!("Cannot use {}: {e}", request.output_dir.display()))?; - - let before = entries(&request.output_dir); - - let mut command = Command::new(&request.bin); - command.args(build_args(request)); - // A template's hooks call out to other programs (git, curl); when - // cookiecutter lives in a conda prefix, so do they, and that prefix is not - // otherwise on the child's PATH. - if let Some(bin_dir) = request.bin.parent() { - command.env("PATH", prepend_path(bin_dir)); - } - for (key, value) in &request.env { - command.env(key, value); - } - - let output = command.output().map_err(|e| { - format!("Could not run {}: {e}", request.bin.display()) - })?; - if !output.status.success() { - return Err(describe_failure(&output)); - } - - let after = entries(&request.output_dir); - let mut created: Vec = after.difference(&before).cloned().collect(); - if created.len() == 1 { - return Ok(request.output_dir.join(created.remove(0))); - } - // Nothing new means the template re-rendered over a directory that was - // already there; fall back to the name the project was asked to have. - if created.is_empty() { - if let Some(name) = request.vars.iter().find(|v| v.key == "project_name") { - let guess = request.output_dir.join(&name.value); - if guess.is_dir() { - return Ok(guess); - } - } - } - Err(format!( - "cookiecutter finished but did not leave a single new folder in {}.", - request.output_dir.display() - )) -} - -/// `dir` followed by the inherited `PATH`. -fn prepend_path(dir: &Path) -> OsString { - let mut dirs = vec![dir.to_path_buf()]; - if let Some(existing) = std::env::var_os("PATH") { - dirs.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(dirs).unwrap_or_else(|_| dir.as_os_str().to_owned()) -} - -/// Turn a failed run into a one-line message, preferring the last meaningful -/// line of stderr — cookiecutter puts the real cause there (a JSON decoding -/// error, a hook that exited non-zero) under a long Python traceback. -fn describe_failure(output: &std::process::Output) -> String { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = last_meaningful_line(&stderr) - .or_else(|| last_meaningful_line(&stdout)) - .unwrap_or_else(|| "no output".to_string()); - format!("cookiecutter failed: {detail}") -} - -/// The last non-blank line that is not part of a Python traceback frame. -fn last_meaningful_line(text: &str) -> Option { - text.lines() - .map(str::trim) - .rfind(|line| { - !line.is_empty() - && !line.starts_with("File \"") - && !line.starts_with('^') - && !line.starts_with('~') - && *line != "Traceback (most recent call last):" - }) - .map(str::to_string) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request(run_hooks: bool) -> Request { - Request { - bin: PathBuf::from("/usr/bin/cookiecutter"), - template: PathBuf::from("/tmpl/snowflake"), - output_dir: PathBuf::from("/out"), - vars: vec![ - Var::new("project_name", "The Winter Gate"), - Var::new("author", "Lane Wight"), - ], - env: Vec::new(), - run_hooks, - } - } - - #[test] - fn args_are_non_interactive_and_carry_the_overrides() { - let args: Vec = build_args(&request(true)) - .iter() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - assert_eq!( - args, - vec![ - "--no-input", - "--output-dir", - "/out", - "--accept-hooks", - "yes", - "/tmpl/snowflake", - "project_name=The Winter Gate", - "author=Lane Wight", - ] - ); - } - - #[test] - fn hooks_can_be_turned_off() { - let args: Vec = build_args(&request(false)) - .iter() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - let at = args.iter().position(|a| a == "--accept-hooks").unwrap(); - assert_eq!(args[at + 1], "no"); - } - - /// The template argument has to precede the `key=value` overrides, or - /// cookiecutter reads the first override as the template to render. - #[test] - fn the_template_comes_before_the_overrides() { - let args: Vec = build_args(&request(true)) - .iter() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - let template = args.iter().position(|a| a == "/tmpl/snowflake").unwrap(); - let first_var = args - .iter() - .position(|a| a.starts_with("project_name=")) - .unwrap(); - assert!(template < first_var, "got {args:?}"); - } - - #[test] - fn a_value_with_spaces_stays_one_argument() { - let args = build_args(&request(true)); - assert!(args - .iter() - .any(|a| a.to_string_lossy() == "project_name=The Winter Gate")); - } - - #[test] - fn generate_rejects_a_folder_that_is_not_a_template() { - let dir = std::env::temp_dir().join("md_manuscript_cc_not_a_template"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut req = request(false); - req.template = dir.clone(); - let err = generate(&req).unwrap_err(); - assert!(err.contains("not a template"), "got {err}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_explicit_binary_path_is_used_when_it_exists() { - let dir = std::env::temp_dir().join("md_manuscript_cc_bin"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let bin = dir.join("cookiecutter"); - std::fs::write(&bin, "#!/bin/sh\n").unwrap(); - assert_eq!( - resolve_binary(bin.to_str().unwrap(), None), - Some(bin.clone()) - ); - // A configured path that is not there resolves to nothing rather than - // silently falling back to some other cookiecutter. - assert_eq!(resolve_binary("/nope/cookiecutter", None), None); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_per_user_python_prefix_is_searched_when_path_lacks_it() { - let home = std::env::temp_dir().join("md_manuscript_cc_home"); - let _ = std::fs::remove_dir_all(&home); - let bin_dir = home.join(".anaconda3/bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - let bin = bin_dir.join("cookiecutter"); - std::fs::write(&bin, "#!/bin/sh\n").unwrap(); - // Only reached because the name is not on this test process's PATH. - if search_path(BIN_NAME).is_none() { - assert_eq!(resolve_binary("", Some(&home)), Some(bin)); - } - let _ = std::fs::remove_dir_all(&home); - } - - #[test] - fn failure_text_skips_the_python_traceback() { - let text = "Traceback (most recent call last):\n \ - File \"/x/hooks.py\", line 1, in run\n \ - ^^^^^\n\ - jinja2.exceptions.TemplateSyntaxError: expected token\n"; - assert_eq!( - last_meaningful_line(text).as_deref(), - Some("jinja2.exceptions.TemplateSyntaxError: expected token") - ); - } - - #[test] - fn failure_text_handles_output_with_nothing_useful() { - assert_eq!(last_meaningful_line(""), None); - assert_eq!(last_meaningful_line(" \n\n \n"), None); - } - - /// Drive the real `cookiecutter` against the real template, which is the - /// only check that the argument list above is one the tool actually accepts. - /// Skips when either is absent, so a machine without them still passes. - /// - /// Hooks are off here to keep the test hermetic: the snowflake template's - /// post-gen hook publishes to a Gitea server when `GITEA_*` is set, and a - /// test must not depend on — or touch — the network. - #[test] - fn generates_a_project_from_the_configured_template() { - let Some(bin) = resolve_binary("", dirs::home_dir().as_deref()) else { - eprintln!("skipping: no cookiecutter on this machine"); - return; - }; - let template = crate::config::default_project_template(); - if !template.join("cookiecutter.json").is_file() { - eprintln!("skipping: no template at {}", template.display()); - return; - } - let out = std::env::temp_dir().join("md_manuscript_cc_e2e"); - let _ = std::fs::remove_dir_all(&out); - - let request = Request { - bin, - template, - output_dir: out.clone(), - vars: vec![ - Var::new("project_name", "Test Project With Spaces"), - Var::new("author", "Nobody"), - Var::new("description", "Generated by a unit test."), - ], - env: Vec::new(), - run_hooks: false, - }; - let project = generate(&request).expect("generation should succeed"); - - // The diff-the-directory approach must find the folder the template - // named from `project_name`, spaces and all. - assert_eq!( - project.file_name().and_then(|n| n.to_str()), - Some("Test Project With Spaces") - ); - assert!(project.is_dir()); - // And the subfolder the app opens as a workspace has to be in there. - let subdir = crate::config::default_project_open_subdir(); - assert!( - project.join(&subdir).is_dir(), - "expected a {subdir} folder in {project:?}" - ); - let _ = std::fs::remove_dir_all(&out); - } -} diff --git a/src/main.rs b/src/main.rs index 2062c43..5b83afa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ mod app; mod characters; mod config; -mod cookiecutter; mod fountain; mod gitea; mod gitsync; diff --git a/src/preprocess.rs b/src/preprocess.rs index e3e5b88..2e870f3 100644 --- a/src/preprocess.rs +++ b/src/preprocess.rs @@ -113,35 +113,25 @@ pub fn split_at_marker<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a /// Split a document at its YAML frontmatter boundaries (`---` delimiters). /// Returns `(frontmatter, body)` if frontmatter exists, or `None` if no frontmatter. /// The frontmatter includes the opening and closing `---` lines. -pub fn split_yaml_frontmatter<'a>(text: &'a str) -> Option<(&'a str, &'a str)> { - let mut lines = text.lines(); - - // Check for opening --- - let first_line = lines.next()?.trim(); - if first_line != "---" { +pub fn split_yaml_frontmatter(text: &str) -> Option<(&str, &str)> { + // `split_inclusive` keeps the line breaks, so the offset is the real byte + // offset. Measuring trimmed lines and adding one for a newline drifts on a + // delimiter written `--- ` and on every CRLF file — and the fold splits the + // buffer at exactly this point, so a drift of one byte strands a character + // at the top of the editor. + let mut lines = text.split_inclusive('\n'); + let opening = lines.next()?; + if opening.trim() != "---" { return None; } - - // Find closing --- - let mut frontmatter_end = first_line.len() + 1; // +1 for newline - let mut found_closing = false; - + let mut end = opening.len(); for line in lines { - frontmatter_end += line.len() + 1; // +1 for newline + end += line.len(); if line.trim() == "---" { - found_closing = true; - break; + return Some(text.split_at(end)); } } - - if !found_closing { - return None; - } - - // Return frontmatter (including delimiters) and body - let frontmatter = &text[..frontmatter_end]; - let body = &text[frontmatter_end..]; - Some((frontmatter, body)) + None } /// One `#* Name: value` line from a document's editorial header. @@ -165,6 +155,13 @@ pub struct Field { /// mistaken for card metadata. pub fn fields(markdown: &str, marker: &str) -> Vec { let text = strip_comments(markdown); + // A file with frontmatter has said everything it has to say by the closing + // `---`, and reading on past it takes prose for metadata: such a file has no + // draft marker to stop at, so scanning the whole document turned a line of + // dialogue like `Bixby: run` into a field. + if let Some((frontmatter, _)) = split_yaml_frontmatter(&text) { + return yaml_fields(frontmatter); + } let marker_norm = marker.trim().to_ascii_lowercase(); let mut out = Vec::new(); for line in text.lines() { @@ -190,6 +187,97 @@ pub fn field(markdown: &str, marker: &str, name: &str) -> Option { .filter(|v| !v.is_empty()) } +/// Every key of a document's YAML frontmatter as a [`Field`], so the status +/// column, the character index and the scene card read frontmatter and a legacy +/// `## POV:` header through one interface. +/// +/// Lists are folded into the comma-separated form those readers already speak, +/// so both +/// +/// ```text +/// characters: +/// - Bixby +/// - Mom +/// ``` +/// +/// and `characters: [Bixby, Mom]` arrive as `Bixby, Mom` — and `characters: []` +/// as nothing, rather than as a character called `[]`. +fn yaml_fields(frontmatter: &str) -> Vec { + let mut out: Vec = Vec::new(); + // Only a key written with nothing after it can be opening a block list, so + // a stray `- ` cannot append itself to a scalar above it. + let mut collecting = false; + for line in frontmatter.lines() { + if line.trim() == "---" { + continue; + } + if let Some(item) = block_list_item(line) { + if collecting { + if let Some(last) = out.last_mut() { + if !last.value.is_empty() { + last.value.push_str(", "); + } + last.value.push_str(&item); + } + } + continue; + } + if let Some((name, value)) = parse_yaml_field(line) { + let value = flow_list_items(&value); + collecting = value.is_empty(); + out.push(Field { name, value }); + } + } + out +} + +/// Look one frontmatter key up, trying each alias in turn. Empty reads as absent, +/// the same way [`field`] treats an unfilled `## POV:`. +fn yaml_value(fields: &[Field], names: &[&str]) -> Option { + names.iter().find_map(|name| { + fields + .iter() + .find(|f| f.name.eq_ignore_ascii_case(name)) + .map(|f| f.value.clone()) + .filter(|v| !v.is_empty()) + }) +} + +/// The item of a `- item` line, with any quotes taken off. +fn block_list_item(line: &str) -> Option { + let rest = line.trim().strip_prefix('-')?; + // `-value` is a scalar, not a list item; YAML wants the space. + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + return None; + } + let item = unquote(rest.trim()); + (!item.is_empty()).then_some(item) +} + +/// `[Bixby, Mom]` as `Bixby, Mom` and `[]` as nothing. Anything that is not a +/// flow list comes back as it went in. +fn flow_list_items(value: &str) -> String { + let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else { + return value.to_string(); + }; + inner + .split(',') + .map(|item| unquote(item.trim())) + .filter(|item| !item.is_empty()) + .collect::>() + .join(", ") +} + +/// Strip one pair of matching surrounding quotes. +fn unquote(value: &str) -> String { + for quote in ['"', '\''] { + if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) { + return value[1..value.len() - 1].to_string(); + } + } + value.to_string() +} + /// Split a comma-separated field value into its items, dropping blanks — the /// form `## Characters: Bixby, Mom, Kayla` uses. pub fn value_items(value: &str) -> Vec { @@ -289,43 +377,29 @@ pub fn parse(markdown: &str, marker: &str) -> Header { let lines: Vec<&str> = cleaned.lines().collect(); let marker_norm = marker.trim().to_ascii_lowercase(); - // Try YAML frontmatter first - if let Some(body_start_line) = find_yaml_frontmatter_end(&lines) { - // Parse metadata from YAML frontmatter - let mut title = None; - let mut slug = None; - let mut pov = None; - let mut goal = None; - - // Extract YAML fields from the frontmatter lines - for line in &lines[..body_start_line] { - // Skip the --- delimiters - if line.trim() == "---" { - continue; - } - // Parse YAML-style key: value lines - if let Some((key, value)) = parse_yaml_field(line) { - match key.to_ascii_lowercase().as_str() { - "title" => title = Some(value), - "slug" => slug = Some(value), - "pov" => pov = Some(value), - "word_count_target" | "word count target" | "wordcounttarget" => { - goal = parse_goal(&value); - } - _ => {} - } - } - } - - // Body is everything after the closing --- - let body_lines: Vec<&str> = lines[body_start_line..].to_vec(); - + // Frontmatter wins: a file that has it keeps its metadata there, and the + // body below the closing `---` is prose and nothing else. Read through the + // same [`yaml_fields`] the status column and the character index use, so + // there is one answer to what a key says. + if let Some((frontmatter, body)) = split_yaml_frontmatter(&cleaned) { + let fields = yaml_fields(frontmatter); + // An explicit min/max pair is the more specific statement, so it wins + // over a single target. + let goal = match ( + yaml_value(&fields, &["word_count_min"]), + yaml_value(&fields, &["word_count_max"]), + ) { + (Some(min), Some(max)) => parse_goal(&format!("{min} - {max}")), + _ => yaml_value(&fields, &["word_count_target", "word count target"]) + .as_deref() + .and_then(parse_goal), + }; return Header { - title: title.filter(|s| !s.is_empty()), - slug: slug.filter(|s| !s.is_empty()), - pov: pov.filter(|s| !s.is_empty()), + title: yaml_value(&fields, &["title"]), + slug: yaml_value(&fields, &["slug"]), + pov: yaml_value(&fields, &["pov"]), goal, - body: body_lines.join("\n").trim().to_string(), + body: body.trim().to_string(), }; } @@ -387,22 +461,6 @@ pub fn parse(markdown: &str, marker: &str) -> Header { } } -/// Find the line index where YAML frontmatter ends (after the closing `---`). -/// Returns Some(body_start_line) where body_start_line is the index after the closing ---. -fn find_yaml_frontmatter_end(lines: &[&str]) -> Option { - if lines.is_empty() || lines[0].trim() != "---" { - return None; - } - - for (i, line) in lines.iter().enumerate().skip(1) { - if line.trim() == "---" { - return Some(i + 1); - } - } - - None -} - /// Parse a YAML field line like `key: value` or `key: "value"`. /// Returns (key, value) if the line looks like a YAML field. /// Strips inline comments (everything after # that's not in quotes). @@ -480,14 +538,9 @@ fn parse_yaml_field(line: &str) -> Option<(String, String)> { let key = trimmed_no_comment[..colon_pos].trim().to_string(); let value = trimmed_no_comment[colon_pos + 1..].trim(); - // Remove surrounding quotes if present - let value = if value.starts_with('"') && value.ends_with('"') { - value[1..value.len() - 1].to_string() - } else if value.starts_with('\'') && value.ends_with('\'') { - value[1..value.len() - 1].to_string() - } else { - value.to_string() - }; + // Remove surrounding quotes if present. The length test matters: a lone + // `"` starts and ends with one, and slicing it would panic. + let value = unquote(value); if key.is_empty() { None @@ -595,6 +648,110 @@ mod tests { assert_eq!(parse(md2, MARKER).pov, None); } + #[test] + fn frontmatter_splits_on_exact_byte_offsets() { + // The fold cuts the buffer here and glues it back on save, so the split + // point has to be right to the byte. Measuring trimmed lines and adding + // one for a newline drifted on a padded delimiter and on CRLF. + for text in [ + "---\ntitle: x\n---\n\nProse.\n", + "--- \ntitle: x\n--- \n\nProse.\n", + "---\r\ntitle: x\r\n---\r\n\r\nProse.\r\n", + "---\ntitle: x\n---", + ] { + let (header, body) = split_yaml_frontmatter(text).expect("frontmatter"); + assert_eq!( + format!("{header}{body}"), + text, + "the two halves must put the file back together" + ); + assert!( + header.trim_end().ends_with("---"), + "header stops at the closing delimiter: {header:?}" + ); + assert!( + !body.contains("title:"), + "the header must not leak into the body: {body:?}" + ); + } + } + + #[test] + fn a_document_without_a_closing_delimiter_has_no_frontmatter() { + assert!(split_yaml_frontmatter("---\ntitle: x\n\nProse.\n").is_none()); + assert!(split_yaml_frontmatter("Prose, starting right away.\n").is_none()); + assert!(split_yaml_frontmatter("").is_none()); + } + + #[test] + fn frontmatter_lists_read_as_items() { + // Both YAML list forms have to reach `value_items`, which is what the + // Characters window matches scenes with. A block list used to read as an + // empty value (so no scene ever matched) and `[]` as one character + // literally called "[]". + let block = "---\ncharacters:\n - Bixby\n - \"Mom\"\nsetting: kitchen\n---\n\nProse.\n"; + assert_eq!( + field(block, "### Rough Draft:", "characters").as_deref(), + Some("Bixby, Mom") + ); + assert_eq!( + value_items(&field(block, "", "characters").unwrap()), + ["Bixby", "Mom"] + ); + // The key after the list is still its own field. + assert_eq!(field(block, "", "setting").as_deref(), Some("kitchen")); + + let flow = "---\ncharacters: [Bixby, \"Mom\"]\n---\n\nProse.\n"; + assert_eq!(field(flow, "", "characters").as_deref(), Some("Bixby, Mom")); + + let empty = "---\ncharacters: []\n---\n\nProse.\n"; + assert_eq!(field(empty, "", "characters"), None); + } + + #[test] + fn frontmatter_keys_may_carry_underscores() { + // `field_line`'s bare form allows only letters, so snake_case keys were + // invisible to the status column and everything else reading `fields`. + let text = "---\nstatus: draft\nword_count_target: 2000\n---\n\nProse.\n"; + assert_eq!(field(text, "", "status").as_deref(), Some("draft")); + assert_eq!( + field(text, "", "word_count_target").as_deref(), + Some("2000") + ); + } + + #[test] + fn prose_below_the_frontmatter_is_not_metadata() { + // A frontmatter file has no draft marker to stop the scan, so a line of + // dialogue used to be read as a field. + let text = "---\npov: Bixby\n---\n\nBixby: run, and don't look back.\n"; + assert_eq!(field(text, "", "pov").as_deref(), Some("Bixby")); + assert_eq!(field(text, "", "Bixby"), None); + assert_eq!(fields(text, "").len(), 1); + } + + #[test] + fn a_min_max_pair_sets_the_goal() { + // Documented alongside `word_count_target`, and more specific than it. + let pair = "---\nword_count_target: 2000\nword_count_min: 1800\nword_count_max: 2200\n---\n\nProse.\n"; + let goal = parse(pair, "").goal.expect("a goal"); + assert_eq!((goal.min, goal.max), (1800, 2200)); + // On its own, the target still stands. + let target = "---\nword_count_target: 1500 - 2000\n---\n\nProse.\n"; + let goal = parse(target, "").goal.expect("a goal"); + assert_eq!((goal.min, goal.max), (1500, 2000)); + } + + #[test] + fn frontmatter_body_is_only_the_prose() { + // What lands in the word count and in the export. + let text = "---\ntitle: \"The Gate\"\nslug: in which the door will not open\n---\n\nThe prose begins here.\n"; + let h = parse(text, "### Rough Draft:"); + assert_eq!(h.title.as_deref(), Some("The Gate")); + assert_eq!(h.slug.as_deref(), Some("in which the door will not open")); + assert_eq!(h.body, "The prose begins here."); + } + #[test] fn parses_yaml_frontmatter() { let md = "---\ntitle: The Gate\nslug: an opening\npov: Bixby\nword_count_target: 1500 - 2000\n---\n\nThe prose begins here.\n\n## A scene"; diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md b/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md index f85fd60..02e7abc 100644 --- a/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md +++ b/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md @@ -1,21 +1,22 @@ --- -# Basic metadata for your scene/chapter -# Everything after the closing --- is your draft content - -id: "" +# Everything this scene knows about itself lives here. +# Below the closing --- is the draft, and nothing else: +# that is what the word count counts and what gets exported. title: "New Scene Title" slug: "" act: 1 chapter: 1 scene: 1 -type: scene status: draft -word_count_target: 2000 pov: "" characters: [] setting: "" date: "" time: "" +conflict: "" +theme: "" +tone: "" +word_count_target: 1800-2200 --- [Write your scene content here...] diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/line_edits/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/line_edits/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/proofreading/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/proofreading/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/structural_edits/notes.md b/templates/snowflake/{{ cookiecutter.project_name }}/06-Revisions/structural_edits/notes.md new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/final_manuscript.md b/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/final_manuscript.md new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/metadata/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/metadata/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/submission_materials/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/submission_materials/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/{{cookiecutter.project_name}} - Full Text.odm b/templates/snowflake/{{ cookiecutter.project_name }}/07-Final/{{cookiecutter.project_name}} - Full Text.odm new file mode 100644 index 0000000000000000000000000000000000000000..00477bf199ec6a63eb15c35d76a2bb9fdf6eca0f GIT binary patch literal 2568 zcmZ`*c{r497k}*gKGwY6Y(--WS$dOX&tPa$wwf^}OPFB>p%FqvmWV`#Mj=Ti%OH`M zP>O72n#uObmdT#|n|j~t^5uHJ`?=2b+|M7s^W5kB?sI+zYQx430{&j|z^NC7H#C?Z zKaHDd{yzRFTo?w0z+n7*kO-U)IzTfdz*7T_!1`b{&=^#JCmI>-j|#wP;82%w>i!5U z4iyCb$0s+C7e{m}IXQT)kLfF$6#&GUZ(DPiiMq9&g{D6uz~>?gi_^I5?^k{6rSB^( z{&)tSm)J0N`+R(2FEt;I2gX!#UuM}p&Ybu*0~)BxY~iJLks`0I^ZTwAOSOu;9u<@R zsE6L4<~D7Fmo~T(*&g~aG*{1MflXq-n9JddLtPf}P107+qh}V^X@w6uZRUX| z3}#yoPFujaXch^j!)I1P->N;-Vh|JF(WX2a2W?vS+-z~O**Jn3F@en$Az?0qv<0EINkFNh``rYqo z@Gjg=^JW8pZb1OxWu^g(3-d!^f5zUQ?T*gX0>v{H!N-bhsd0-Y1En6;hx~i1l6=cD zSL%bBh0$Y$k!jEq959p;d(U;FC32~TsxfftU=9OA0`3${y=}9y^&d%iUK9$ zL1^%rI3e7ttmkCwVLl(>#HVjn&XS=GnG%Yz`|n9c2WmDtE}Te<5ob9ltp23C#gm39p<>0q@)dIJ{WUcCeB*drtOl#MvSP2?LO`9f-O_wKAt(hsZQ~I4p3F za@4j>x!WoCYTOzKHxQq`KQQ(^C!$Qmz;}NoAa$5WHO2dm1Q-+DJK6gqEo=a+*@&$v zB>8fb7sh=wtt>Is$(qZe@4J;WiZp(A&Zw`nTnuy4kUiGM{zme5JF|B%vU|9`nKr9S zm>6x?gH#dbg}m5&NKEPiBKbx4oy^jchUrS_N?-2A2~+O5TON1|@unrwoHM3#G7ppn z4!g19N~P4RG^Epc#?B>PnX;8$m8Dv!D3b>AwC(1zNS=I~TT@HHsD5I+b*E_5>N7P^ zQm6&H*Zd)h^I#9-uqVfcz;+G++{-p%Z7r(h&hU2f=QABkPEA#SzRA4982v>^mFK{rUDt2T`=SvJ=q1j!=T zsO)@%R{XZs>e@~#?J6Z=C+N>H!4Mm=5FLmBZKgU-p~Xh~c`E9fMdD`pLF$ZY4=_Ya z&Z8FP68P7=YGYw(*CW~6EP8#yU>)*$%W=4IN&uJme5!-zHZ;A>lN^C1=J8i#E~{r` zYRLmSt<}$Ta6C&VL!czZF^-S#!HXD3ZZf^VDc@B>l`KF=nOQ6)4M?Ws*YEX+Aevt2U;zLZb^zdG)*})ffcx3Um<4$oW`w3{3DdT~Px6MMZ{1U4 zL8zYiAmM&PZO%_?T-8G%J;th9J7#lLZW-~WV-zBh%Ahx_;s#b~D!@OI%Jtt}^vifG zU2w*3-$rJoaRXs9o;Fcd4(267y^HH*{D(U~ooE6B)3xW5SEXlMzFZTDtl$`bA8U%EVZ`+WQ;K4J2-@=>Y%rt^GXzq_tSslV-wu@v_f zWO2Myl8iSI5c(2YaO#s89AU+I%1^}LeKy26+*x0|WiUmeP_DhGzjg8R8ZcfTJIv`~ z_zW+m^HTU*k-#hY_?*fO5i$Z<>Epg%csf~}w9r1mLwCS1>Ye15dZJZa)wHverdn(0 zdHJHnY`K_`?zAHP9*UAx2oL2oV!JwCP+!ex-7fn+_Q(W@|DqRb+lTgpu|7N5JvU!i zRF5dzc{D*OS9`a88^1i=Ts~vC>H!Uowy;vU?j=gIoTO59Ef%HUl?mll**v&pY*R-( zIo?2YM^1TO$<6;py|49TeEM?a4yt{J?RUm;c+P|wCezQEtp5@>6b|u|?we^Q_Dt=G zV{C{!%$1jt3re~Kt^Se^l=gy&RKkp)x}SZ>@2?R!)<&ji(xY{aP%J|3dj>M3aLe*LB_654kl8|EDWxpDJ8 z%PT*}OVynv$}jI)ywDNig-a1NRYTwc1+V=e(A(mhk90Phsvh5SHXiSWIDE1Fn-PsoV+xLg!7A#a$*&onFwhw};W`Xx%iDTf7HwD`?e z^A#4(^Lm&#Wd!5_g3t#VH_ZHC#R+3b_VjwKq4k}nL7|E(q4qAiJP`)7E5ivyNFrM9 zNj5ejMqan^E%ruiM(Yu=Y^PFClJ{7iz|&FvVT8LBs;{UWd7w?yL9VqrfvOvcsrlZZ zCt=CGQqjjd11Z|@5~V|JSXe=9|9>)J9{1OZVv@N#r|hEsefIbp0{~GhFy{7OCy~7_ z_UiVo3s>f^cU62ZZf}3t#mz7~&i{0qy}-TKbQehDWJdbWrQM6#%jGU=j_Y@3p*9>$ SKLEhVd|a3z-1%7xz<&W=@K);p literal 0 HcmV?d00001 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/08-Covers/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/08-Covers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/09-Archive/.gitkeep b/templates/snowflake/{{ cookiecutter.project_name }}/09-Archive/.gitkeep new file mode 100644 index 0000000..e69de29