Finish bundled-template migration; simplify frontmatter; de-duplicate toolbar/menu
Completes the move off the external cookiecutter tool: drops src/cookiecutter.rs and the cookiecutter_bin/Template-path setting now that scaffolding always runs through the built-in MiniJinja engine, and rewrites default_project_template() to walk up from the executable without panicking when the bundled template can't be found. Rounds out the snowflake template with the remaining 06-Revisions, 07-Final, 08-Covers and 09-Archive folders. Simplifies the scene/chapter YAML frontmatter (drops type/word_count_min/ word_count_max in favor of status + a flat characters list) and updates the new-file template, header parsing, outline, file list and Story Bible editor to match. Separately, de-duplicates the toolbar and menu bar: settings that only existed as always-visible toolbar controls (draft marker, zero-pad, spell-check toggle, dictionary picker) move into their Settings windows, toolbar buttons that exactly duplicated a menu item are dropped, and toolbar-only actions with no menu path (open project folder, git sync/init) gain one under File. Also fixes two ambiguous menu labels: the Story Bible editor now reads "Story Bible editor…" next to "Story Bible…" in Tools instead of sharing a label with Edit, and Settings' "New project…" is now "New-project settings…" so it doesn't read like File's actual create action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 | `# <name>` 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.
|
||||
|
||||
+269
-131
@@ -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<usize> = None;
|
||||
let mut toggled: Option<String> = None;
|
||||
let mut dropped: Option<FileDrop> = 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(
|
||||
// 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 is_manuscript_root {
|
||||
ui.label(
|
||||
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(
|
||||
// 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(
|
||||
// `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),
|
||||
);
|
||||
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 {
|
||||
if show_bar {
|
||||
row_goal_bar(
|
||||
ui,
|
||||
bar_w,
|
||||
BAR_W,
|
||||
goal,
|
||||
meta.prose_words,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(status) = &meta.status {
|
||||
ui.label(
|
||||
egui::RichText::new(status_tag(status))
|
||||
if show_status {
|
||||
ui.add_sized(
|
||||
[STATUS_W, 20.0],
|
||||
egui::Label::new(
|
||||
egui::RichText::new(status_tag(
|
||||
status,
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
)
|
||||
.on_hover_text(format!("Status: {status}"));
|
||||
.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")
|
||||
.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.
|
||||
///
|
||||
|
||||
+15
-1
@@ -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<String>,
|
||||
/// 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<crate::app::auto_snapshot::SnapshotInfo>,
|
||||
/// 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,11 +518,21 @@ 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)) {
|
||||
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.
|
||||
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D)) {
|
||||
|
||||
+209
-49
@@ -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<BeatTree> },
|
||||
/// A heading, holding the beats written under it and any headings nested
|
||||
/// inside it.
|
||||
Heading { name: String, children: Vec<BeatTree> },
|
||||
/// 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<BeatTree> {
|
||||
let mut tree: Vec<BeatTree> = 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<String> = None;
|
||||
let mut current_children: Vec<BeatTree> = 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),
|
||||
});
|
||||
insert_beat(&mut tree, &beat.path, beat);
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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<BeatTree>, 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<String> {
|
||||
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
|
||||
|
||||
<!-- Opening Image: A snapshot of the ordinary world. -->
|
||||
|
||||
She wakes before the alarm and lies there listening.
|
||||
|
||||
<!-- Inciting Incident: Something disrupts it. -->
|
||||
|
||||
<!-- Lock-In: The hero commits. -->
|
||||
|
||||
### Sequence 2
|
||||
|
||||
<!-- Debate: The hero hesitates. -->
|
||||
";
|
||||
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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-10
@@ -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()
|
||||
{
|
||||
|
||||
+41
-1
@@ -254,12 +254,52 @@ impl App {
|
||||
let mut remove: Option<String> = 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<String> = 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 \
|
||||
|
||||
@@ -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;
|
||||
@@ -276,11 +280,11 @@ impl App {
|
||||
|
||||
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.
|
||||
|
||||
+45
-128
@@ -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<String> = 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 {
|
||||
(
|
||||
|
||||
+152
-141
@@ -453,6 +453,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the open file has a header to fold away.
|
||||
pub(super) fn header_is_foldable(&self) -> bool {
|
||||
header_is_foldable(&self.buffer, &self.config.draft_marker)
|
||||
}
|
||||
|
||||
/// Lift the header out of the buffer, leaving the prose. Supports both
|
||||
/// YAML frontmatter (`---` delimiters) and the legacy draft marker.
|
||||
pub(super) fn collapse_header(&mut self) {
|
||||
@@ -650,11 +655,9 @@ impl App {
|
||||
self.rebuild_field_names();
|
||||
}
|
||||
|
||||
/// Create a new scene file with merged YAML frontmatter containing both
|
||||
/// planning metadata and draft content. Used for the "New Scene" button.
|
||||
/// Create a new scene file: its planning metadata as frontmatter, and the
|
||||
/// prose below it. Used for the "New Scene" button.
|
||||
pub(super) fn create_scene_pair(&mut self) {
|
||||
use super::today_utc;
|
||||
|
||||
// Determine which folder we're in
|
||||
let current_dir = self
|
||||
.selected
|
||||
@@ -678,8 +681,6 @@ impl App {
|
||||
};
|
||||
|
||||
let act = act_num.unwrap_or(1);
|
||||
let today = today_utc();
|
||||
|
||||
// Find next available scene number in this act
|
||||
let existing_scenes: Vec<u32> = self.files.iter()
|
||||
.filter(|f| {
|
||||
@@ -717,141 +718,7 @@ impl App {
|
||||
// Scene file
|
||||
let filename = format!("{}/scene{:02}.md", draft_dir, scene_num);
|
||||
|
||||
// Scene ID
|
||||
let scene_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
|
||||
|
||||
// Create merged file with YAML frontmatter
|
||||
let content = format!(r#"---
|
||||
id: {}
|
||||
title: "{}"
|
||||
slug: {}
|
||||
act: {}
|
||||
chapter: {}
|
||||
scene: {}
|
||||
type: scene
|
||||
status: draft
|
||||
word_count_target: 2000
|
||||
word_count_min: 1800
|
||||
word_count_max: 2200
|
||||
pov: ""
|
||||
characters: []
|
||||
setting: ""
|
||||
date: ""
|
||||
time: ""
|
||||
conflict: ""
|
||||
theme: ""
|
||||
tone: ""
|
||||
word_count: 0
|
||||
word_count_updated: ""
|
||||
session_word_count: 0
|
||||
last_session: ""
|
||||
total_sessions: 0
|
||||
version: 1
|
||||
created: "{}"
|
||||
updated: "{}"
|
||||
order: {}
|
||||
---
|
||||
|
||||
# {}
|
||||
|
||||
**Scene File** | **ID:** {} | **Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## Scene Metadata
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| **Act** | {} |
|
||||
| **Chapter** | {} |
|
||||
| **Scene** | {} |
|
||||
| **Type** | Scene |
|
||||
| **POV** | Not set |
|
||||
| **Version** | 1 |
|
||||
| **Word Count Target** | 2000 |
|
||||
|
||||
---
|
||||
|
||||
## Word Count Tracking
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Target** | 2000 |
|
||||
| **Minimum** | 1800 |
|
||||
| **Maximum** | 2200 |
|
||||
| **Current** | 0 |
|
||||
| **This Session** | 0 |
|
||||
| **Last Updated** | Never |
|
||||
|
||||
---
|
||||
|
||||
## Creative Elements
|
||||
|
||||
### POV
|
||||
> Not specified
|
||||
|
||||
### Characters
|
||||
|
||||
### Setting
|
||||
> Not specified
|
||||
|
||||
### Date & Time
|
||||
> Not specified | Not specified
|
||||
|
||||
---
|
||||
|
||||
## Story Structure
|
||||
|
||||
### Conflict
|
||||
> Not specified
|
||||
|
||||
### Theme
|
||||
> Not specified
|
||||
|
||||
### Tone
|
||||
> Not specified
|
||||
|
||||
---
|
||||
|
||||
[Write your scene content here...]
|
||||
|
||||
---
|
||||
|
||||
## Writing Notes
|
||||
|
||||
### Session Log
|
||||
| Date | Words Added | Notes |
|
||||
|------|--------------|-------|
|
||||
|
||||
|
||||
### Revision Notes
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## Status Checklist
|
||||
|
||||
- [ ] Planning complete
|
||||
- [ ] First draft complete
|
||||
- [ ] Word count target met
|
||||
- [ ] POV consistent
|
||||
- [ ] Character voices distinct
|
||||
- [ ] Setting details accurate
|
||||
- [ ] Conflict resolved/advanced
|
||||
- [ ] Ready for revision
|
||||
|
||||
---
|
||||
|
||||
**Created:** {} | **Updated:** {}
|
||||
"#,
|
||||
scene_id, title, slug, act, chapter, scene_num,
|
||||
scene_num,
|
||||
today, today,
|
||||
title, scene_id,
|
||||
act, chapter, scene_num,
|
||||
today, today
|
||||
);
|
||||
let content = scene_file_contents(&title, &slug, act, chapter, scene_num);
|
||||
|
||||
// Create the merged file
|
||||
self.insert_new_file(filename.clone(), content);
|
||||
@@ -1956,6 +1823,51 @@ pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String {
|
||||
format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@'))
|
||||
}
|
||||
|
||||
/// Whether a document has a header to fold away — YAML frontmatter, or a legacy
|
||||
/// block above the draft marker.
|
||||
///
|
||||
/// Split out from [`App::header_is_foldable`] so the choice can be exercised
|
||||
/// without an `App`. The two forms are asked about together everywhere the fold
|
||||
/// is offered, so they are asked about here once: the toggle above the editor
|
||||
/// used to ask only about the draft marker, which meant a frontmatter file —
|
||||
/// the one form the app now writes — never offered the fold at all.
|
||||
fn header_is_foldable(buffer: &str, marker: &str) -> bool {
|
||||
crate::preprocess::split_yaml_frontmatter(buffer).is_some()
|
||||
|| crate::preprocess::split_at_marker(buffer, marker).is_some()
|
||||
}
|
||||
|
||||
/// A new scene file: everything it knows about itself in the frontmatter, and
|
||||
/// nothing below the closing `---` but the prose placeholder.
|
||||
///
|
||||
/// The body used to restate the whole header — a metadata table, a word-count
|
||||
/// table, POV/characters/setting/conflict/theme/tone sections, a notes block and
|
||||
/// a checklist. All of that is *body*, which is what the word count counts and
|
||||
/// what the exporter writes into the .odt, so every scene opened a hundred and
|
||||
/// fifty words and half a page into its own export before a line was written.
|
||||
fn scene_file_contents(title: &str, slug: &str, act: u32, chapter: u32, scene: u32) -> String {
|
||||
format!(
|
||||
"---\n\
|
||||
title: \"{title}\"\n\
|
||||
slug: {slug}\n\
|
||||
act: {act}\n\
|
||||
chapter: {chapter}\n\
|
||||
scene: {scene}\n\
|
||||
status: draft\n\
|
||||
pov: \"\"\n\
|
||||
characters: []\n\
|
||||
setting: \"\"\n\
|
||||
date: \"\"\n\
|
||||
time: \"\"\n\
|
||||
conflict: \"\"\n\
|
||||
theme: \"\"\n\
|
||||
tone: \"\"\n\
|
||||
word_count_target: 1800-2200\n\
|
||||
---\n\
|
||||
\n\
|
||||
[Write your scene content here...]\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Expand the template placeholders and guarantee a trailing newline.
|
||||
pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String {
|
||||
let mut out = template
|
||||
@@ -1972,6 +1884,104 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frontmatter_scene_offers_the_fold() {
|
||||
// An explicit marker: the default is empty now that scenes are written
|
||||
// as frontmatter, so it would not exercise the legacy arm.
|
||||
let marker = "### Rough Draft:";
|
||||
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
|
||||
assert!(header_is_foldable(&scene, marker), "frontmatter should fold");
|
||||
// A frontmatter file has no draft marker, which is exactly why asking
|
||||
// only about the marker hid the toggle on every scene the app writes.
|
||||
assert!(crate::preprocess::split_at_marker(&scene, marker).is_none());
|
||||
|
||||
// The legacy form still folds, and prose alone still does not.
|
||||
let legacy = "# Title: The Gate\n\n### Rough Draft:\n\nProse.\n";
|
||||
assert!(header_is_foldable(legacy, marker), "a draft marker should fold");
|
||||
assert!(!header_is_foldable("Just prose.\n", marker), "nothing to fold");
|
||||
// Clearing the marker disables legacy splitting but not frontmatter,
|
||||
// which is delimited by `---` rather than by the marker.
|
||||
assert!(!header_is_foldable(legacy, ""));
|
||||
assert!(header_is_foldable(&scene, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_a_scene_leaves_the_prose_and_keeps_the_file_whole() {
|
||||
// The fold is a view: `document()` glues the stash back on for every
|
||||
// save, so what is on disk must not change for having been folded.
|
||||
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
|
||||
let (header, body) =
|
||||
crate::preprocess::split_yaml_frontmatter(&scene).expect("frontmatter");
|
||||
assert!(
|
||||
!body.contains("word_count_target"),
|
||||
"no frontmatter left in the editor: {body:?}"
|
||||
);
|
||||
assert_eq!(body.trim(), "[Write your scene content here...]");
|
||||
assert_eq!(format!("{header}{body}"), scene, "the file is unchanged");
|
||||
// What the toggle's label counts while folded.
|
||||
assert_eq!(crate::preprocess::fields(header, "").len(), 15);
|
||||
}
|
||||
|
||||
/// The template the snowflake project ships and the file "+ New Scene"
|
||||
/// writes have to stay the same shape, so check the bundled one too.
|
||||
const BUNDLED_TEMPLATE: &str =
|
||||
include_str!("../../templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md");
|
||||
|
||||
#[test]
|
||||
fn a_new_scene_puts_nothing_but_prose_below_the_frontmatter() {
|
||||
// The body is what the word count counts and what the exporter writes,
|
||||
// so the header must not be restated in it. It used to be, at length.
|
||||
for source in [
|
||||
scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 1, 1, 3),
|
||||
BUNDLED_TEMPLATE.to_string(),
|
||||
] {
|
||||
let header = crate::preprocess::parse(&source, "### Rough Draft:");
|
||||
assert_eq!(header.body, "[Write your scene content here...]");
|
||||
assert!(
|
||||
count_words(&header.body) < 10,
|
||||
"a new scene should not open with a page of metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_scene_says_each_thing_once() {
|
||||
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
|
||||
let fields = crate::preprocess::fields(&source, "### Rough Draft:");
|
||||
let mut names: Vec<String> = fields.iter().map(|f| f.name.to_lowercase()).collect();
|
||||
names.sort();
|
||||
let before = names.len();
|
||||
names.dedup();
|
||||
assert_eq!(before, names.len(), "a key is written twice: {names:?}");
|
||||
// Nothing the app cannot keep true: these were stale the moment you
|
||||
// typed, and `order`/`id` restated order.json and the file's own path.
|
||||
for gone in [
|
||||
"id", "type", "version", "order", "created", "updated", "word_count",
|
||||
"word_count_updated", "session_word_count", "last_session", "total_sessions",
|
||||
] {
|
||||
assert!(!names.contains(&gone.to_string()), "{gone} is back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_scene_is_read_back_through_the_frontmatter() {
|
||||
// The point of favouring YAML: every reader gets its answer from there.
|
||||
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
|
||||
let header = crate::preprocess::parse(&source, "### Rough Draft:");
|
||||
assert_eq!(header.title.as_deref(), Some("Chapter 1: Scene 3"));
|
||||
assert_eq!(header.slug.as_deref(), Some("chapter-1--scene-3"));
|
||||
let goal = header.goal.expect("the target is readable");
|
||||
assert_eq!((goal.min, goal.max), (1800, 2200));
|
||||
assert_eq!(
|
||||
crate::preprocess::field(&source, "", "status").as_deref(),
|
||||
Some("draft")
|
||||
);
|
||||
assert_eq!(crate::preprocess::field(&source, "", "act").as_deref(), Some("2"));
|
||||
// Unfilled reads as absent, so an empty `pov:` is not a POV of "".
|
||||
assert_eq!(crate::preprocess::field(&source, "", "pov"), None);
|
||||
assert_eq!(crate::preprocess::field(&source, "", "characters"), None);
|
||||
}
|
||||
|
||||
/// The folder the "open the project folder" button points at, across the
|
||||
/// shapes a workspace can take.
|
||||
#[test]
|
||||
@@ -2284,3 +2294,4 @@ mod tests {
|
||||
assert_eq!(nearest_listed(&[], Some("05-First Draft"), 0), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-32
@@ -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");
|
||||
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;
|
||||
}
|
||||
// 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");
|
||||
let candidate = cwd.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.");
|
||||
// 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
|
||||
|
||||
@@ -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<Var>,
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<OsString> {
|
||||
let mut args: Vec<OsString> = 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<OsString> {
|
||||
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<PathBuf, String> {
|
||||
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<OsString> = 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<String> {
|
||||
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<String> = 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<String> = 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<String> = 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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
mod app;
|
||||
mod characters;
|
||||
mod config;
|
||||
mod cookiecutter;
|
||||
mod fountain;
|
||||
mod gitea;
|
||||
mod gitsync;
|
||||
|
||||
+239
-82
@@ -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<Field> {
|
||||
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<String> {
|
||||
.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<Field> {
|
||||
let mut out: Vec<Field> = 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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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<String> {
|
||||
@@ -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<usize> {
|
||||
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";
|
||||
|
||||
@@ -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...]
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user