21 Commits

Author SHA1 Message Date
landon d29aa664da Parse a scene's summary as a real list of bullet points
A frontmatter `summary:` list written as a multi-line flow sequence
(one bullet per line, closing bracket on its own line — the natural
way to hand-write several sentences) used to vanish: no line in it
has a colon or a leading `-`, so the parser read none of the lines
after the opening `summary: [` and the field ended up holding a
literal "[". Even written on one line, `flow_list_items` split on
every comma, which cuts a prose bullet in two if it has one of its
own.

`Header` now has a `summary: Vec<String>`, read by a parser that
joins a flow sequence's continuation lines before splitting it on
commas outside of quotes, so each bullet survives whole. Also added
`summary: []` to the bundled scene template and the in-app default
new-file template, so new scenes offer the field.

No new UI surfaces this yet (corkboard/synopsis still show only the
slug) — out of scope for the parsing fix itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 20:00:36 -05:00
landon 3d6e134a33 Lock the scene template against accidental edits
_template.md is a reference writers copy from by hand, not something
the app reads programmatically, so an accidental edit to it is easy
to make and easy to miss. The editor now shows it read-only with a
lock banner; an "Unlock" button allows editing for the rest of the
session, resetting back to locked the next time a file is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 10:46:52 -05:00
landon a700eed63c Refresh Story Bible reference ranges when the header folds or unfolds
Folding/unfolding the header moves the frontmatter in or out of
`buffer`, shifting every char offset into it, but after_view_change()
never recomputed current_references — so the cached ranges stayed
pointing at pre-toggle positions and highlights landed on unrelated
words. Recompute them alongside the other view-state invalidations it
already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 15:32:17 -05:00
landon 80f3d50fc4 Stop the file tree from centering short folder/file names
ui.add_sized() centers a widget narrower than the box it's given, and
Button/SelectableLabel size themselves to their actual text — so a
short name in a wide column (a folder with no trailing badges, most
visibly) drifted away from its own indent, landing to the right of
its own deeper children. Pin main_align to the start so every name
sits right after its indent regardless of length.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 15:32:13 -05:00
landon 8b352d25ed Let the Story Bible Editor rename/move files
Right-clicking a file row now offers "Rename / Move…", mirroring the
manuscript file panel's combined rename-and-move pattern: editing the
name renames, editing the folder part moves it across the tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 14:08:40 -05:00
landon 7e4932176b Let the Story Bible Editor create new entries, not just edit existing ones
Right-click a folder in the tree (a top-level category like
01-Characters, or any subfolder like cities) for "Add <type> entry…" —
the type is guessed from the folder's own name (singularized: cities
-> city, Objects -> object), editable afterward like any other field.
Names a file, writes minimal id/name/aliases/summary/type/status
frontmatter, and loads it straight into the editor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 08:51:33 -05:00
landon 6627ae8000 Give the User Guide real breathing room between paragraphs
Every paragraph, note and entry was a separate label stacked with only
egui's default item spacing between them, so sections didn't read as
clear breaks. Scope a larger item_spacing to the User Guide's own Ui
(not the shared section/note/body helpers) so the Markdown Cheatsheet,
which uses the same helpers, is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 08:07:06 -05:00
landon 1cd560d954 help documentation 2026-09-30 17:48:03 -05:00
landon 6134cc90ff story bible resize 2026-09-30 17:21:51 -05:00
landon 2f3780a578 Give Misc Scenes a stub file so it actually shows in the file panel
The panel infers folders from markdown file paths; a folder with only
a .gitkeep never gets a row. Act 1/2/3 avoid this with their own
scene01.md stubs — Misc Scenes was missing one, so new projects
shipped it invisible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:22:14 -05:00
landon ab009e0a19 Let a folder be excluded from export without hiding it
Misc Scenes (05-First Draft) is a dumping ground for scenes written
but not yet placed in the book. It should stay visible, editable and
counted everywhere in the app, but never end up in an exported
document. hidden_folders/archive_folder both hide a folder outright,
so add a narrower export_exclude_folders setting (defaults to
["Misc Scenes"]) that only the three export paths honor, with chapter
numbers renumbered afterward so excluded scenes don't leave gaps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:53:48 -05:00
landon 7854a961a9 Bundle the project template into the binary; add outline-to-draft
links, a sprint timer, placeholder scanning, corkboard, and a
quick-capture inbox

- Compile templates/snowflake into the binary via include_dir, so New
  project… never depends on files installed beside the executable;
  Settings ▸ Template becomes an optional external override, and a
  broken saved override now falls back to the bundled template
  instead of failing project generation.
- Outline beats now link to a matching chapter for a one-click jump.
- Sprint window: a daily whole-manuscript word goal plus a
  Pomodoro-style writing sprint timer, with a compact countdown in the
  status bar while in Distraction-free mode.
- Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???]
  markers left in the draft and jump straight to any of them.
- Snowflake progress strip in the file panel: a nudge-only readout of
  which snowflake stage folders actually have content.
- Corkboard window: drag-reorderable synopsis cards, one per chapter.
- Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into
  the project's Scratch Pad without losing your place in the draft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:38:31 -05:00
landon 6ca8681d08 Add workspace search, synopsis view, snapshot diffs, typewriter mode, and a writing streak
Five productivity features: search/replace across the whole workspace
(with a safety commit before Replace all), a Synopsis window compiling
every chapter's slug, a Diff button per Auto-Snapshot to compare against
an older snapshot instead of only HEAD, typewriter-style cursor centering
and paragraph dimming gated on Distraction-free mode, and a per-day
word-count streak persisted to word_log.json alongside order.json/titles.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:55:15 -05:00
landon 229e2413c4 Add summary: to character templates; fix two bugs that were hiding it
Add a summary: field (with a short explanatory comment) to
01_character_template.md, and fill the field in on the two shipped
examples, 01_protagonist.md and 02_antagonist.md. Documented in the
Story Bible README's "Adding a New Character" example and its new
"Automatic Linking in the Draft" section, and in the main README's
Story Bible docs.

While wiring this up against the real bundled template rather than a
hand-written test fixture, found two bugs that were silently defeating
last session's auto-linking feature for every project made from that
template:

- extract_from_frontmatter() only read a title: field for an item's
  name, but every bundled Story Bible template — character, location,
  lore, object, all of them — writes name:, never title:. Every real
  item's display name (and therefore everything matched against it in
  prose) was falling back to a garbled, uncapitalized filename guess
  instead. Now name: is read (title: still works too, for a sheet that
  uses that word instead).

- build_editor_job()'s fast path skipped straight to an unformatted
  run whenever there were no grammar/spelling issues and no active
  search — without checking whether there was a Story Bible reference
  to highlight. A name with no spelling issue of its own (which is
  most of them) never got its purple background, and with it no visible
  way to find the hover card that depends on it.

Both were invisible in the unit tests added last session because they
exercised find_references() directly against a hand-built index, never
through StoryBibleIndex::build() against real files, and the manual
smoke test happened to use a name ("Bixby") that the spell checker
flagged anyway — which incidentally kept the fast path from short-
circuiting. Added a disk-built-index test and a fast-path regression
test so both stay caught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:32:56 -05:00
landon 0da52c3054 Fix Story Bible Editor's content panel rendering a sliver wide
ui.set_width() was called on the shared horizontal-layout Ui before the
tree and editor panels were split off, so it shrank the whole row —
tree, separator, and editor panel together — down to a third of the
window's width instead of just the tree column. The editor's own
heading (the open item's path) was narrow enough to wrap one letter
per line as a result. Moving the width constraint onto the tree
panel's own child Ui, where it belongs, leaves the editor panel free
to take the rest of the row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:10:02 -05:00
landon ab9aa2afce Auto-link Story Bible mentions in the draft, with a hover card to the entry
Bare proper nouns in prose ("Bixby walked in") now resolve against the
Story Bible automatically, the same as the existing [[wiki-links]] and
@mentions markup — no manual linking required. Matching is case-sensitive
and requires a capitalized, 3+ character match to a sheet's name or alias,
which is what keeps a character named "Hope" or "Will" from lighting up
on every unrelated use of the word; the longest phrase wins an overlap
("Bixby Reynolds" over "Bixby"), and a bare match never overrides the
author's own explicit markup.

Hovering a highlighted mention shows a card with the item's name, type
(Person/Place/Thing) and a summary (from a new summary:/description:
frontmatter field), with a link that opens the entry in the Story Bible
editor. The card appears after a short delay and stays open while the
pointer crosses onto it to reach the link.

Along the way:
- StoryBibleReference now carries absolute byte offsets (start/end) and
  the resolved item_id, replacing column/length, which were actually
  per-line and were being used as whole-buffer offsets — every reference
  past a file's first line highlighted and jumped-to in the wrong place.
  find_references() also now walks the buffer one char at a time rather
  than one byte, which could previously land mid-character and panic on
  any manuscript with accented letters or an em dash.
- build_story_bible_index() had its own, separately hardcoded list of
  Story Bible directory names that had drifted from
  find_story_bible_directory()'s list and no longer included
  03-Story-Bible, the bundled template's actual folder — so the index,
  and everything built on it, was silently empty for every project made
  from that template. It now reuses the one list.
- Story Bible Editor window: its tree and content ScrollAreas (and the
  content TextEdit) had no id_salt, so egui treated them as the same
  widget — fixed with explicit salts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:01:54 -05:00
landon ef8032b300 Remove orphaned template folders and the dead create_scene.py script
07-Revisions/08-Final/09-Covers/10-Archive were leftovers from before
the stage renumbering to 06-Revisions/07-Final/08-Covers/09-Archive —
identical content under a second set of numbers that every new
project was scaffolding twice.

scripts/create_scene.py (and its README) hasn't been runnable since
project_template.rs stopped executing template hooks — the native
"+ New Scene" button replaced it — and its docs already pointed at
folder names (05-Plot, 06-First Draft) that predate the current
layout. Dropping it removes a Python-interpreter dependency the app
itself never used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:39:41 -05:00
landon 0f144737a5 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>
2026-09-19 10:35:52 -05:00
landon c61a7b605a fix: Add effective_project_template() to fallback to bundled template
- Add effective_project_template() method that checks if saved template path exists
- If not, falls back to default_project_template() (bundled template)
- This handles users who have old config.json pointing to deleted Cookiecutters path
- Update project.rs to use effective_project_template() when creating new projects

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 18:49:46 -05:00
landon 3392b7e4d3 fix: Start walking from executable directory, not file path
The previous fix started walking from the executable file itself, but we need to
start from its parent directory. Changed:
  let mut current = exe_path.as_path();
to:
  let mut current = exe_path.parent().unwrap_or_else(|| exe_path.as_path());

This ensures we check directories, not the file itself.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 18:45:01 -05:00
landon 341202f87c fix: Improve bundled template path resolution and add tests
- Walk up directory tree from executable to find templates/snowflake
- Handles various build configurations (release, debug, cargo run, installed)
- Add test to verify bundled template path contains 'templates/snowflake'
- Add test to verify template has 05-First Draft (merged) and not old 05-Plot/06-First Draft

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 18:38:03 -05:00
50 changed files with 5022 additions and 2013 deletions
Generated
+20
View File
@@ -1659,6 +1659,25 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1854,6 +1873,7 @@ dependencies = [
"dirs",
"eframe",
"egui",
"include_dir",
"minijinja",
"pulldown-cmark",
"rfd",
+4
View File
@@ -24,6 +24,10 @@ ureq = { version = "2", default-features = false, features = ["tls"] }
spellbook = "0.4.2"
# Templating engine for project scaffolding (replaces cookiecutter dependency)
minijinja = { version = "2", features = ["builtins", "loader"] }
# Embeds the bundled project template (templates/snowflake) into the binary
# at compile time, so New project… never depends on files installed beside
# the executable.
include_dir = "0.7.4"
[profile.release]
opt-level = 2
+153 -81
View File
@@ -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
@@ -45,6 +45,12 @@ sudo apt install libgl1 libxkbcommon0 libwayland-client0 libx11-6
install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript
```
That's it — the **File ▸ New project…** template is compiled into the binary,
so there's nothing else to install alongside it. If you want to use a
customized copy of the template instead of the bundled one, point
**Settings ▸ New project… ▸ Template** at a folder of your own containing a
`cookiecutter.json`; leave it blank to keep using the bundled template.
Optionally add a desktop launcher at
`~/.local/share/applications/md-manuscript.desktop`:
@@ -184,11 +190,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 +201,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 +370,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 +386,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…)
@@ -375,35 +409,47 @@ it shows:
The Story Bible window indexes all Story Bible items from your project's character,
location, lore, and other reference directories. It supports:
* Automatic indexing from directories like `03-Characters`, `02-Story Bible`,
`Characters`, `Locations`, `Lore`, etc.
* Two reference syntaxes in your draft text:
* Automatic indexing from the first directory found: `03-Story-Bible` (the
bundled template's own folder), then `03-Characters`, `02-Story Bible`,
`Characters`, `Locations`, `Lore`, and so on.
* **Automatic linking in the draft — no markup required.** A name spelled and
capitalized the same way as an item's `name:` or an `aliases:` entry is
matched wherever it appears in ordinary prose ("**Bixby** stood at the
door"). Two explicit syntaxes still work too, and win any overlap with an
automatic match:
- Wiki-style: `[[Character Name]]` or `[[John Doe]]`
- Mention-style: `@CharacterName` or `@John`
* **Purple background highlighting** of Story Bible references in the editor
* **Purple background highlighting** of every match — automatic or explicit —
in the editor. Hovering one shows a card with the item's name, type, and a
summary (see below), and a link to open its full entry; the card stays put
while you move the pointer onto it to click that link.
* Click items in the Story Bible window to see all references to them in the current file
* Click reference buttons to jump directly to that location in the editor
* Metadata extraction from YAML frontmatter (id, title, type, **aliases** fields) or from filenames
* Metadata extraction from YAML frontmatter (id, name, type, **aliases**,
**summary** fields) or from filenames
* Case-insensitive search and lookup
* **Aliases support**: References match item IDs, names, **and aliases** for flexible linking
Character sheets in the `Characters` folder are automatically indexed, and any
markdown file with YAML frontmatter containing `id:` and `title:` fields will be
included in the index. You can also add an `aliases:` field with alternative names
for a character or item:
markdown file with YAML frontmatter containing `id:` and `name:` fields will be
included in the index (`title:` is read too, if a sheet uses that word instead).
You can also add `aliases:` for alternative names, and `summary:` for the one-line
hover card:
```yaml
---
id: john_smith
title: "John Smith"
name: "John Smith"
type: character
aliases: ["Johnny", "JS"]
summary: "A retired locksmith dragged back for one last job."
---
```
With aliases defined, references like `[[Johnny]]` or `@JS` will match the "John Smith"
character entry. References in your draft text that match Story Bible items
by ID, name, or alias are highlighted and clickable.
With aliases defined, references like `[[Johnny]]`, `@JS`, or a bare "Johnny" in
prose will all match the "John Smith" character entry. Every reference in your
draft that matches a Story Bible item — by ID, name, or alias — is highlighted
and clickable, whether or not you typed any markup to get it there.
### Story Bible Editor (Edit ▸ Story Bible…)
@@ -608,19 +654,22 @@ 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
bundled in the `templates/snowflake/` folder of the project, which you can modify
by hand to customise the project structure or file contents.
The app uses **built-in MiniJinja templating** (no external Python cookiecutter
required) to render templates. A default "snowflake" template is compiled
into the binary itself — nothing has to be installed alongside it. Its source
lives in the `templates/snowflake/` folder of this repository, for anyone
building from source who wants to customise it (a rebuild picks up the
change); at runtime, point **Settings ▸ New project… ▸ Template** at a folder
of your own to use a customised copy instead, without rebuilding.
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
@@ -636,14 +685,16 @@ You can use your own templates by pointing the **Template** setting in
**Settings ▸ New project…** at any folder containing a `cookiecutter.json` file.
The template should contain a subdirectory named with a template expression
(e.g., `{{ cookiecutter.project_name }}`) which becomes the project root after
rendering.
rendering. Leave the setting blank (the default) to use the bundled template;
an override that stops resolving to a valid template (folder moved, deleted)
silently falls back to the bundled one rather than failing project creation.
### Settings ▸ New project…
| 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** | an external folder holding a `cookiecutter.json` to use instead of the bundled template. Blank (the default) uses the template compiled into the binary |
| **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 +715,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 +734,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 +762,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 +954,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 +1026,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.
+11 -2
View File
@@ -103,7 +103,7 @@ impl App {
}
/// Get the HEAD commit hash.
fn get_head_commit_hash(workspace: &std::path::Path) -> Option<String> {
pub(super) fn get_head_commit_hash(workspace: &std::path::Path) -> Option<String> {
let (ok, out) = crate::gitsync::run_git(workspace, &["rev-parse", "HEAD"]);
if ok {
out.lines()
@@ -115,7 +115,7 @@ impl App {
}
/// Commit only (no pull/push) - used for snapshots.
fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome {
pub(super) fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome {
let mut log = String::new();
let mut ok = true;
@@ -299,6 +299,15 @@ impl App {
ui.label(format!("at {}", snapshot.formatted_time()));
ui.label(format!("{}...", &snapshot.message[..snapshot.message.len().min(30)]));
if ui
.button("Diff")
.on_hover_text("Compare the current file against this snapshot")
.clicked()
{
let label = format!("the {} snapshot", snapshot.formatted_time());
self.open_diff_against(Some(snapshot.commit_hash.clone()), label);
}
if ui.button("Rollback").clicked() {
self.rollback_to_snapshot(orig_index);
}
+108
View File
@@ -0,0 +1,108 @@
//! The Corkboard window: one card per chapter, showing its title and
//! synopsis, drag-reorderable — a restructuring view for the planning phase
//! that draws on the same data the Synopsis window compiles.
use super::*;
impl App {
/// The Corkboard window: chapter cards in manuscript order, draggable to
/// reorder the manuscript itself.
pub(super) fn corkboard_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_corkboard;
// Collect up front so the window closure only reads. `indices[i]` is
// `rows[i]`'s real position in `self.files` — `manuscript_files()`'s
// own index numbers chapters, not flat file-list positions, so it
// can't be used directly as a `reorder` argument.
let rows = self.manuscript_synopsis_rows();
let indices: Vec<usize> = rows
.iter()
.map(|(name, _, _)| self.files.iter().position(|f| f == name).unwrap_or(0))
.collect();
let mut jump_to: Option<String> = None;
let mut reorder_to: Option<(usize, usize)> = None;
egui::Window::new("Corkboard")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(640.0)
.default_height(480.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(
"One card per chapter, in manuscript order. Drag a card to \
reorder the manuscript; click a title to open it.",
)
.small()
.weak(),
);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if rows.is_empty() {
ui.label("No manuscript files yet.");
return;
}
ui.horizontal_wrapped(|ui| {
for (i, (name, title, slug)) in rows.iter().enumerate() {
let idx = indices[i];
let card = ui
.dnd_drag_source(
egui::Id::new(("corkboard", name.as_str())),
idx,
|ui| {
ui.group(|ui| {
ui.set_width(160.0);
ui.set_min_height(110.0);
if ui
.link(egui::RichText::new(title).strong())
.clicked()
{
jump_to = Some(name.clone());
}
ui.add_space(4.0);
match slug.as_deref().map(str::trim) {
Some(s) if !s.is_empty() => {
ui.label(egui::RichText::new(s).small());
}
_ => {
ui.label(
egui::RichText::new(
"— no synopsis —",
)
.weak()
.italics()
.small(),
);
}
}
});
},
)
.response;
drop_highlight(ui, &card);
if let Some(payload) = card.dnd_release_payload::<usize>() {
reorder_to = Some((*payload, idx));
}
}
});
});
});
if let Some(name) = jump_to {
if let Some(sel) = self.files.iter().position(|f| *f == name) {
self.select(sel);
}
}
if let Some((from, to)) = reorder_to {
if from != to {
self.reorder(from, to);
}
}
self.show_corkboard = open;
}
}
+17 -5
View File
@@ -3,13 +3,24 @@
use super::*;
impl App {
/// Load the diff for the current file, ready for the diff window.
/// Load the diff for the current file against the last commit, ready for
/// the diff window.
pub(super) fn open_diff(&mut self) {
self.open_diff_against(None, "the last commit".to_string());
}
/// Load the diff for the current file against `base` (`None` = `HEAD`, the
/// last commit; `Some(hash)` = an older Auto-Snapshot), ready for the diff
/// window. `label` is the human-readable description of `base` shown in
/// the window's header.
pub(super) fn open_diff_against(&mut self, base: Option<String>, label: String) {
self.save_current();
let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else {
self.status = "Open a file first".to_string();
return;
};
self.diff_base = base.clone();
self.diff_base_label = label.clone();
// Git is run from the work tree, and the workspace may be below it.
let root = self.repo_root.clone();
let Some(root) = root else {
@@ -29,9 +40,10 @@ impl App {
.strip_prefix(&root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| name.clone());
self.diff_text = match gitsync::diff_file(&root, &rel) {
let base_ref = base.as_deref().unwrap_or("HEAD");
self.diff_text = match gitsync::diff_file_at(&root, &rel, base_ref) {
Ok(text) if text.trim().is_empty() => {
format!("{name} matches the last commit — no changes.")
format!("{name} matches {label} — no changes.")
}
Ok(text) => text,
Err(e) => format!("Could not diff {name}:\n{e}"),
@@ -56,7 +68,7 @@ impl App {
refresh = true;
}
ui.label(
egui::RichText::new("compared with the last commit")
egui::RichText::new(format!("compared with {}", self.diff_base_label))
.small()
.weak(),
);
@@ -92,7 +104,7 @@ impl App {
});
});
if refresh {
self.open_diff();
self.open_diff_against(self.diff_base.clone(), self.diff_base_label.clone());
}
self.show_diff = open;
}
+240 -4
View File
@@ -5,6 +5,32 @@ use super::*;
impl App {
pub(super) fn editor(&mut self, ui: &mut egui::Ui) {
// The bundled scene template is a reference writers copy from by
// hand, not something the app reads — so an edit to it just sits
// there, invisible, until someone notices the scene they copied from
// it looks wrong. Lock it against typing until explicitly unlocked.
let is_template = self
.selected
.and_then(|i| self.files.get(i))
.is_some_and(|f| workspace::is_template_file(f));
let locked = is_template && !self.template_unlocked;
if is_template {
ui.horizontal(|ui| {
if locked {
ui.label("🔒 This is the scene template — locked to prevent accidental edits.");
if ui.button("Unlock").clicked() {
self.template_unlocked = true;
}
} else {
ui.label("🔓 Unlocked for editing.");
if ui.button("Lock").clicked() {
self.template_unlocked = false;
}
}
});
ui.add_space(4.0);
}
// Base monospace size scaled by the zoom percentage. `desired_width` stays
// infinite and the scroll area is vertical-only, so text wraps to the pane
// width at any zoom (no horizontal scrolling).
@@ -39,6 +65,13 @@ impl App {
None
};
// Typewriter mode: only while Distraction-free is active (both panels
// hidden — the same condition `toggle_distraction_free` uses), and
// only if the writer hasn't opted out.
let distraction_free = !self.config.show_toolbar && !self.config.show_file_panel;
let typewriter = self.config.typewriter_mode && distraction_free;
let dim_range = typewriter.then(|| current_paragraph_range(&self.buffer, self.cursor_pos));
egui::ScrollArea::vertical()
.id_salt("editor")
.auto_shrink([false, false])
@@ -47,7 +80,7 @@ impl App {
let bible_ranges: Vec<(usize, usize)> = self
.current_references
.iter()
.map(|r| (r.column, r.column + r.length))
.map(|r| (r.start, r.end))
.collect();
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
@@ -59,6 +92,7 @@ impl App {
&find_ranges,
find_active,
&bible_ranges,
dim_range,
wrap_width,
);
ui.fonts(|f| f.layout_job(job))
@@ -105,6 +139,7 @@ impl App {
.desired_width(f32::INFINITY)
.desired_rows(rows)
.layouter(&mut layouter)
.interactive(!locked)
.show(ui);
if output.response.changed() {
self.dirty = true;
@@ -140,10 +175,16 @@ impl App {
}
}
// Whether some explicit jump (an issue pick, a find-match step)
// already scrolled the view this frame — typewriter centering
// stands down when one did, rather than fighting it.
let mut jumped = false;
// Take the editor to an issue picked in the results panel:
// select the words, centre them, and hand focus back so the
// user can type the correction straight away.
if let Some((start, end)) = self.issue_jump.take() {
jumped = true;
let (cs, ce) = (
byte_to_char(&self.buffer, start),
byte_to_char(&self.buffer, end),
@@ -170,6 +211,7 @@ impl App {
// open, Next/Prev, Enter, or after a replace).
if self.find_scroll {
if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
jumped = true;
let char_idx = self
.buffer
.get(..s)
@@ -183,6 +225,25 @@ impl App {
self.find_scroll = false;
}
// Typewriter mode: keep the caret's line centered, the way a
// typewriter's platen does. Only re-centers when the cursor
// actually moved since last frame, so scrolling the view by
// hand between keystrokes isn't immediately undone.
let new_cursor_pos = output
.cursor_range
.map(|cr| char_to_byte(&self.buffer, cr.primary.ccursor.index))
.unwrap_or(self.cursor_pos);
if typewriter && !jumped && new_cursor_pos != self.cursor_pos {
if let Some(cr) = output.cursor_range {
let rect = output
.galley
.pos_from_ccursor(cr.primary.ccursor)
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
self.cursor_pos = new_cursor_pos;
// Right-click a misspelling → a menu of suggested corrections.
if output.response.secondary_clicked() {
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
@@ -335,6 +396,48 @@ impl App {
} else {
self.autocomplete = None;
}
// Which Story Bible mention (if any) is the pointer over right
// now, and where would its hover card anchor?
let hovered_ref = output.response.hover_pos().and_then(|pos| {
let local = pos - output.galley_pos;
let cursor = output.galley.cursor_from_pos(local);
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
self.current_references
.iter()
.find(|r| byte >= r.start && byte < r.end)
});
match hovered_ref {
Some(r) if self.bible_hover_item.as_deref() != Some(r.item_id.as_str()) => {
let (cs, ce) = (
byte_to_char(&self.buffer, r.start),
byte_to_char(&self.buffer, r.end),
);
let rect = output
.galley
.pos_from_ccursor(egui::text::CCursor::new(cs))
.union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce)))
.translate(output.galley_pos.to_vec2());
self.bible_hover_item = Some(r.item_id.clone());
self.bible_hover_since = Some(Instant::now());
self.bible_hover_anchor = rect;
}
Some(_) => {} // the same word is still hovered — keep its timer/anchor
None => {
// Not over a mention — only drop the card if the
// pointer isn't over the card itself either (the user
// moving toward its "Open" link).
let over_card = self
.bible_hover_card_rect
.zip(ui.ctx().pointer_hover_pos())
.is_some_and(|(rect, pos)| rect.contains(pos));
if !over_card {
self.bible_hover_item = None;
self.bible_hover_since = None;
self.bible_hover_card_rect = None;
}
}
}
});
// Header-field autocomplete popup, floating just below the caret.
@@ -358,6 +461,69 @@ impl App {
if let Some(field) = clicked_field {
self.apply_field_completion(ui.ctx(), &field);
}
// Story Bible hover card: name, type and summary for the mentioned
// item, with a link to open its full entry. Shown after a short
// delay so it doesn't flash across every name the pointer passes
// over on its way somewhere else, and it stays open while the
// pointer crosses onto the card to reach that link (tracked via
// `bible_hover_card_rect`, set below and checked above).
const HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(350);
let mut open_bible_item: Option<String> = None;
let hover = self.bible_hover_item.clone().zip(self.bible_hover_since);
self.bible_hover_card_rect = None;
if let Some((item_id, since)) = hover {
let elapsed = since.elapsed();
if elapsed < HOVER_DELAY {
ui.ctx().request_repaint_after(HOVER_DELAY - elapsed);
} else if let Some(item) = self.story_bible_index.find_by_id(&item_id).cloned() {
let resp = egui::Area::new(egui::Id::new("bible_hover_card"))
.order(egui::Order::Foreground)
.fixed_pos(self.bible_hover_anchor.left_bottom() + egui::vec2(0.0, 4.0))
.constrain(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_max_width(280.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&item.name).strong());
ui.label(
egui::RichText::new(crate::story_bible::type_label(
&item.item_type,
))
.weak()
.small(),
);
});
match &item.summary {
Some(s) => {
ui.label(s);
}
None => {
ui.label(
egui::RichText::new("No summary yet.").weak().italics(),
);
}
}
ui.separator();
if ui.link("Open in Story Bible \u{2192}").clicked() {
open_bible_item = Some(item.path.clone());
}
});
});
self.bible_hover_card_rect = Some(resp.response.rect);
} else {
// The item vanished (sheet deleted, index gone stale) — stop trying.
self.bible_hover_item = None;
self.bible_hover_since = None;
}
}
if let Some(path) = open_bible_item {
self.show_story_bible_editor = true;
self.load_story_bible_item(&path);
self.bible_hover_item = None;
self.bible_hover_since = None;
self.bible_hover_card_rect = None;
}
}
}
@@ -377,6 +543,7 @@ pub(super) fn build_editor_job(
find: &[(usize, usize)],
find_active: Option<usize>,
bible: &[(usize, usize)],
dim: Option<(usize, usize)>,
wrap_width: f32,
) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat};
@@ -384,13 +551,14 @@ pub(super) fn build_editor_job(
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
if grammar.is_empty() && find.is_empty() {
if grammar.is_empty() && find.is_empty() && bible.is_empty() && dim.is_none() {
job.append(text, 0.0, TextFormat::simple(font_id, color));
return job;
}
// Split the text at every match boundary (grammar, find, and bible), then format
// each run according to which ranges cover it.
// Split the text at every match boundary (grammar, find, bible, and the
// typewriter-focus paragraph), then format each run according to which
// ranges cover it.
let mut points: Vec<usize> = vec![0, text.len()];
for &(s, e, _) in grammar {
points.push(s);
@@ -404,6 +572,10 @@ pub(super) fn build_editor_job(
points.push(s);
points.push(e);
}
if let Some((s, e)) = dim {
points.push(s);
points.push(e);
}
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
points.sort_unstable();
points.dedup();
@@ -445,6 +617,12 @@ pub(super) fn build_editor_job(
if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) {
fmt.background = bible_bg;
}
// Typewriter-focus mode: fade every run outside the current paragraph.
if let Some((ds, de)) = dim {
if !(a >= ds && b <= de) {
fmt.color = fmt.color.gamma_multiply(0.35);
}
}
job.append(&text[a..b], 0.0, fmt);
}
job
@@ -521,6 +699,17 @@ pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
s.char_indices().take_while(|(b, _)| *b < byte).count()
}
/// The byte range of the paragraph containing `pos`: from just after the
/// nearest blank line (`"\n\n"`) before it to just before the nearest one
/// after it, or the buffer's edges when there is none. Used by typewriter
/// mode to know which paragraph to keep in focus.
pub(super) fn current_paragraph_range(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind("\n\n").map_or(0, |i| i + 2);
let end = text[pos..].find("\n\n").map_or(text.len(), |i| pos + i);
(start, end)
}
/// Apply an inline-format action to `text` given a selection in *character*
/// indices, returning the new text and the new selection (also character indices).
///
@@ -704,6 +893,53 @@ pub(super) fn toggle_line_comment(
mod tests {
use super::*;
#[test]
fn a_bible_only_reference_is_not_skipped_by_the_grammar_find_fast_path() {
// build_editor_job short-circuits to one plain run when there's
// nothing to highlight — a regression check that it doesn't take
// that shortcut when the only thing to highlight is a Story Bible
// mention (no grammar issues, no active search), which used to
// mean the purple background — and the hover card that depends on
// it being visible to find — never appeared for a name with no
// spelling issue of its own.
let text = "Protagonist stood at the door.";
let job = build_editor_job(
text,
14.0,
egui::Color32::WHITE,
&[],
&[],
None,
&[(0, 11)], // "Protagonist"
None,
f32::INFINITY,
);
let highlighted = job
.sections
.iter()
.any(|s| s.format.background != egui::Color32::default());
assert!(highlighted, "a bible-only reference must still be formatted");
}
#[test]
fn current_paragraph_range_finds_the_blank_line_boundaries() {
let text = "First paragraph.\n\nSecond paragraph,\nwrapped.\n\nThird.";
// A position in the second paragraph should not reach into the first
// or third.
let pos = text.find("Second").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Second paragraph,\nwrapped.");
// The first paragraph has no blank line before it; the buffer start
// is the boundary.
let (s, e) = current_paragraph_range(text, 0);
assert_eq!(&text[s..e], "First paragraph.");
// The last paragraph has no blank line after it; the buffer end is
// the boundary.
let pos = text.find("Third").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Third.");
}
#[test]
fn byte_to_char_is_the_inverse_of_char_to_byte() {
// "café!" — é is 2 bytes, so byte and char indices diverge after it.
+306 -135
View File
@@ -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
@@ -221,6 +233,97 @@ impl App {
});
}
ui.separator();
self.snowflake_progress_section(ui);
// 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;
@@ -248,18 +351,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,21 +395,52 @@ 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);
// `add_sized` centers a widget
// smaller than the box it's given
// — fine for a fixed-width badge,
// but it let a short folder name
// drift away from the indent that
// was just drawn for it, and
// further than its own deeper
// children whose names fill more
// of their (narrower) box. Pinning
// `main_align` to the start keeps
// the name anchored right after
// the indent regardless of length.
let button_response = ui
.add_sized(
[name_w, 20.0],
.allocate_ui_with_layout(
egui::vec2(name_w, 20.0),
egui::Layout::left_to_right(egui::Align::Center)
.with_main_align(egui::Align::Min),
|ui| {
ui.add(
egui::Button::new(text)
.frame(false)
.truncate(),
)
},
)
.inner
.on_hover_text(&row.path);
if button_response.clicked() {
@@ -313,17 +456,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 +538,75 @@ 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(
[label_w, 20.0],
egui::SelectableLabel::new(selected, text),
);
// `SelectableLabel` has no truncate
// of its own and lays its text out
// with `Extend`, so `add_sized` does
// not hold it to `label_w` — a long
// file name simply spills out of the
// panel. The wrap mode is what it
// actually reads.
//
// `add_sized` also *centers* a
// widget narrower than the box
// it's given, which drifted short
// file names away from the indent
// just like folder names did —
// `with_main_align(Min)` pins it
// to the start instead.
let mut label = ui
.scope(|ui| {
ui.style_mut().wrap_mode =
Some(egui::TextWrapMode::Truncate);
ui.allocate_ui_with_layout(
egui::vec2(label_w, 20.0),
egui::Layout::left_to_right(egui::Align::Center)
.with_main_align(egui::Align::Min),
|ui| {
ui.add(egui::SelectableLabel::new(
selected, text,
))
},
)
.inner
})
.inner;
if let Some(tooltip) = &tooltip {
label = label.on_hover_text(tooltip);
}
@@ -426,20 +622,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 +691,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 +744,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;
}
}
});
}
});
}
}
@@ -680,7 +818,7 @@ fn file_context_menu(ui: &mut egui::Ui, name: &str, idx: usize, action: &mut Opt
/// Outline a whole-row drop target while a file is dragged over it, and report
/// whether it is being hovered (so the caller can look for the release).
fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
pub(super) fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
if response.dnd_hover_payload::<usize>().is_none() {
return false;
}
@@ -694,12 +832,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.
///
+188 -3
View File
@@ -20,20 +20,27 @@ mod autocomplete;
mod auto_snapshot;
mod beats;
mod characters;
mod corkboard;
mod diff;
mod editor;
mod file_list;
mod find;
mod grammar;
mod outline;
mod placeholders;
mod project;
mod quick_capture;
mod snowflake_progress;
mod spelling;
mod sprint;
mod story_bible_editor;
mod story_bible_ui;
mod style;
mod synopsis;
mod ui;
mod util;
mod workspace;
mod workspace_search;
// Pulled up so the sibling modules reach these free helpers through their own
// `use super::*;`. Modules whose helpers are only used locally aren't listed.
@@ -189,6 +196,10 @@ pub struct App {
/// Per-file chapter title overrides (file name -> title). Missing/empty means
/// the title is derived automatically at export time.
titles: HashMap<String, String>,
/// Per-day manuscript word-count log, for the writing streak in the
/// status bar. Lives in the workspace (`word_log.json`) and syncs with
/// git the same way `titles`/`order.json` do.
word_log: crate::word_log::WordLog,
/// Editing buffer for the selected file's chapter title override.
title_input: String,
selected: Option<usize>,
@@ -198,6 +209,16 @@ pub struct App {
/// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>,
dirty: bool,
/// Whether the currently open `_template.md` has been unlocked for
/// editing this session. Not persisted and not file-specific — it resets
/// to locked whenever a different file is selected, so re-opening the
/// template always starts protected again.
template_unlocked: bool,
/// Byte offset of the primary cursor in `buffer`, as of the last frame.
/// Used by typewriter mode to notice the cursor moved (and re-center it)
/// versus the writer scrolling the view manually, and to find the current
/// paragraph to keep in focus.
cursor_pos: usize,
/// Editable copy of the workspace path shown in the top bar.
workspace_input: String,
/// Editable copy of the export path.
@@ -262,6 +283,8 @@ pub struct App {
settings_test_status: String,
/// Whether the markdown cheatsheet window is open.
show_cheatsheet: bool,
/// Whether the User Guide window is open.
show_user_guide: bool,
/// Whether the find/replace bar above the editor is visible.
show_find: bool,
/// The search text.
@@ -283,6 +306,19 @@ pub struct App {
find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame.
find_scroll: bool,
/// Whether the workspace-wide search window is visible.
show_workspace_search: bool,
/// The workspace search text.
ws_query: String,
/// The workspace replacement text.
ws_replace: String,
/// Whether the workspace search is case-sensitive.
ws_case_sensitive: bool,
/// The last workspace search's results: one entry per file with at least
/// one match — its name, total match count, and a few line snippets.
ws_results: Vec<(String, usize, Vec<String>)>,
/// One-line status for the workspace search window.
ws_status: String,
/// Active header-field autocomplete popup, if any.
autocomplete: Option<Autocomplete>,
/// A partial the user dismissed with Esc; the popup stays closed until the
@@ -321,6 +357,22 @@ pub struct App {
show_manuscript_settings: bool,
/// Whether the Outline window is open.
show_outline: bool,
/// Whether the Synopsis window is open.
show_synopsis: bool,
/// Whether the Sprint window is open.
show_sprint: bool,
/// Whether the Placeholders window is open.
show_placeholders: bool,
/// Whether the Corkboard window is open.
show_corkboard: bool,
/// Whether the quick-capture popup is open.
show_quick_capture: bool,
/// The pending note's text, entered in the quick-capture popup.
quick_capture_text: String,
/// When the active writing sprint started, if any.
sprint_start: Option<Instant>,
/// Whole-manuscript word count when the active sprint started.
sprint_start_words: usize,
/// Whether to open an outline file from a folder context menu.
/// Contains the workspace-relative path of the outline file to open.
open_outline_file: Option<String>,
@@ -328,6 +380,12 @@ pub struct App {
show_diff: bool,
diff_text: String,
diff_title: String,
/// Commit the diff window is comparing against. `None` means the last
/// commit (`HEAD`); `Some(hash)` means an older Auto-Snapshot picked from
/// the Snapshots window.
diff_base: Option<String>,
/// Human-readable label for `diff_base`, shown in the diff window's header.
diff_base_label: String,
/// Text field for adding a word by hand.
wordlist_input: String,
/// Index (into the currently displayed matches) of the word a right-click
@@ -383,12 +441,53 @@ pub struct App {
selected_bible_item: Option<String>,
/// References found in the current draft file.
current_references: Vec<crate::story_bible::StoryBibleReference>,
/// The Story Bible item whose hover card is (or was just) showing over the
/// editor, so the card can stay open while the pointer crosses from the
/// mentioned word onto the card itself to click its link.
bible_hover_item: Option<String>,
/// When the current hover began, so the card only appears after a short
/// delay rather than flickering across every name the pointer passes over.
bible_hover_since: Option<Instant>,
/// On-screen rect of the mentioned word, to anchor the hover card under.
bible_hover_anchor: egui::Rect,
/// On-screen rect the hover card itself occupied last frame, so hovering
/// the card (to reach its link) counts as still hovering the reference.
bible_hover_card_rect: Option<egui::Rect>,
/// Whether the Story Bible breakout editor window is open.
show_story_bible_editor: bool,
/// The content being edited in the Story Bible editor.
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,
/// Whether the "add Story Bible entry" dialog is open.
show_add_story_bible_item: bool,
/// Directory (relative to the Story Bible folder) the new entry will be
/// created in, set by the tree's right-click menu.
add_story_bible_dir: String,
/// Entry type inferred from that directory's name, pre-filled into the
/// new file's `type:` field — editable like any other field once created.
add_story_bible_type: String,
/// The dialog's name field.
add_story_bible_name: String,
/// Set alongside `show_add_story_bible_item` so the name field takes
/// focus on the frame the dialog opens.
add_story_bible_focus: bool,
/// Whether the "rename/move Story Bible item" dialog is open.
show_rename_story_bible: bool,
/// The dialog's path field, pre-filled with the current path (no `.md`)
/// when opened, as both a rename and a move, same as the manuscript
/// panel's `rename_input`.
rename_story_bible_input: String,
/// The Story Bible item being renamed/moved: its full path (with `.md`),
/// set by the tree's right-click menu. There's no `selected` index to
/// recover this from the way the manuscript panel does.
rename_story_bible_target: Option<String>,
/// Set alongside `show_rename_story_bible` so the path field takes focus
/// on the frame the dialog opens.
rename_story_bible_focus: bool,
/// List of auto-snapshots for rollback.
snapshots: Vec<crate::app::auto_snapshot::SnapshotInfo>,
/// Word count at the last auto-snapshot.
@@ -409,11 +508,14 @@ impl App {
files: Vec::new(),
collapsed: HashSet::new(),
titles: HashMap::new(),
word_log: crate::word_log::WordLog::default(),
title_input: String::new(),
selected: None,
buffer: String::new(),
header_stash: None,
dirty: false,
template_unlocked: false,
cursor_pos: 0,
new_name: String::new(),
rename_input: String::new(),
pending_delete: false,
@@ -442,6 +544,7 @@ impl App {
settings_test_rx: None,
settings_test_status: String::new(),
show_cheatsheet: false,
show_user_guide: false,
show_find: false,
find_query: String::new(),
replace_query: String::new(),
@@ -452,6 +555,12 @@ impl App {
find_needs_refresh: false,
find_focus: None,
find_scroll: false,
show_workspace_search: false,
ws_query: String::new(),
ws_replace: String::new(),
ws_case_sensitive: false,
ws_results: Vec::new(),
ws_status: String::new(),
autocomplete: None,
ac_dismissed: None,
field_names: Vec::new(),
@@ -470,10 +579,20 @@ impl App {
show_characters: false,
show_manuscript_settings: false,
show_outline: false,
show_synopsis: false,
show_sprint: false,
sprint_start: None,
sprint_start_words: 0,
show_placeholders: false,
show_corkboard: false,
show_quick_capture: false,
quick_capture_text: String::new(),
open_outline_file: None,
show_diff: false,
diff_text: String::new(),
diff_title: String::new(),
diff_base: None,
diff_base_label: "the last commit".to_string(),
wordlist_input: String::new(),
spell_menu: None,
dismissed: HashSet::new(),
@@ -499,9 +618,23 @@ impl App {
show_story_bible: false,
selected_bible_item: None,
current_references: Vec::new(),
bible_hover_item: None,
bible_hover_since: None,
bible_hover_anchor: egui::Rect::NOTHING,
bible_hover_card_rect: None,
show_story_bible_editor: false,
story_bible_editor_content: String::new(),
story_bible_editor_path: None,
story_bible_editor_focused: false,
show_add_story_bible_item: false,
add_story_bible_dir: String::new(),
add_story_bible_type: String::new(),
add_story_bible_name: String::new(),
add_story_bible_focus: false,
show_rename_story_bible: false,
rename_story_bible_input: String::new(),
rename_story_bible_target: None,
rename_story_bible_focus: false,
snapshots: Vec::new(),
last_snapshot_word_count: 0,
show_snapshots: false,
@@ -514,24 +647,46 @@ 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)) {
self.toggle_distraction_free();
}
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
// Ctrl+F opens find (search focused); Ctrl+Shift+F searches the whole
// workspace instead; Ctrl+H opens find focused on replace.
if ctx.input(|i| i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::F))
{
self.open_find(true);
}
if ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::F))
{
self.open_workspace_search();
}
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) {
self.open_find(false);
}
// Ctrl+Shift+N opens the quick-capture popup, for jotting a stray
// idea down without losing your place in the draft.
if ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::N))
{
self.show_quick_capture = true;
}
// Esc closes the find bar.
if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.show_find = false;
@@ -635,6 +790,14 @@ impl eframe::App for App {
self.story_bible_editor_window(ctx);
}
if self.show_add_story_bible_item {
self.add_story_bible_item_window(ctx);
}
if self.show_rename_story_bible {
self.rename_story_bible_item_window(ctx);
}
if self.show_snapshots {
self.snapshots_window(ctx);
}
@@ -646,6 +809,25 @@ impl eframe::App for App {
if self.show_outline {
self.outline_window(ctx);
}
if self.show_synopsis {
self.synopsis_window(ctx);
}
if self.show_sprint {
self.sprint_window(ctx);
}
if self.show_placeholders {
self.placeholders_window(ctx);
}
if self.show_corkboard {
self.corkboard_window(ctx);
}
if self.show_quick_capture {
self.quick_capture_window(ctx);
}
if self.show_workspace_search {
self.workspace_search_window(ctx);
}
if self.show_diff {
self.diff_window(ctx);
@@ -658,6 +840,9 @@ impl eframe::App for App {
if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
}
if self.show_user_guide {
crate::help::user_guide_window(ctx, &mut self.show_user_guide);
}
if self.pending_repo.is_some() {
self.repo_prompt(ctx);
+288 -49
View File
@@ -10,79 +10,117 @@ 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);
}
}
/// A manuscript chapter as `(file name, resolved title, synopsis slug)`, the
/// same shape [`App::manuscript_synopsis_rows`] returns.
type ChapterRow = (String, String, Option<String>);
/// Match an outline beat's name against a chapter's resolved title or its
/// synopsis slug, case-insensitively. Returns the matching chapter's index in
/// `chapters`. Most beats won't line up with a chapter name — that's normal,
/// not an error, and simply means no "open draft" link is offered for them.
fn match_beat_to_chapter(beat_name: &str, chapters: &[ChapterRow]) -> Option<usize> {
let needle = beat_name.trim().to_lowercase();
if needle.is_empty() {
return None;
}
chapters.iter().position(|(_, title, slug)| {
title.trim().to_lowercase() == needle
|| slug
.as_deref()
.is_some_and(|s| s.trim().to_lowercase() == needle)
})
}
/// 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. `open_file` collects
/// a chapter to jump to when a beat's "open draft" link is clicked.
fn render_beat_tree(
ui: &mut egui::Ui,
tree: &[BeatTree],
salt: &str,
chapters: &[ChapterRow],
open_file: &mut Option<String>,
) {
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, chapters, open_file);
});
}
BeatTree::Beat { name, filled } => {
ui.horizontal_wrapped(|ui| {
let mark = if *filled { "[x]" } else { "[ ]" };
let text = egui::RichText::new(format!(" {mark} {}", name));
ui.label(if *filled { text } else { text.weak() });
if let Some(idx) = match_beat_to_chapter(name, chapters) {
if ui.link(egui::RichText::new("→ open draft").small()).clicked() {
*open_file = Some(chapters[idx].0.clone());
}
}
});
}
}
}
@@ -154,6 +192,7 @@ impl App {
let mut close = false;
let mut open_file: Option<String> = None;
let files = self.read_outline();
let chapters = self.manuscript_synopsis_rows();
egui::Window::new("Outline")
.open(&mut open)
@@ -220,7 +259,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, &chapters, &mut open_file);
});
}
});
@@ -240,3 +279,203 @@ 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());
}
fn chapters() -> Vec<ChapterRow> {
vec![
(
"01-Arrival.md".to_string(),
"She Arrives".to_string(),
Some("A storm strands her in port.".to_string()),
),
(
"02-Knock.md".to_string(),
"02 — Knock".to_string(),
None,
),
]
}
#[test]
fn a_beat_matches_a_chapter_by_exact_title() {
assert_eq!(match_beat_to_chapter("She Arrives", &chapters()), Some(0));
}
#[test]
fn a_beat_match_is_case_insensitive() {
assert_eq!(match_beat_to_chapter("she arrives", &chapters()), Some(0));
}
#[test]
fn a_beat_matches_a_chapter_by_its_synopsis_slug() {
assert_eq!(
match_beat_to_chapter("A storm strands her in port.", &chapters()),
Some(0)
);
}
#[test]
fn a_beat_with_no_matching_chapter_finds_nothing() {
assert_eq!(match_beat_to_chapter("Midpoint", &chapters()), None);
}
#[test]
fn an_empty_beat_name_finds_nothing() {
assert_eq!(match_beat_to_chapter(" ", &chapters()), None);
}
#[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",
]
);
}
}
+84
View File
@@ -0,0 +1,84 @@
//! The Placeholders window: every `[TODO]`/`[TK]`/`[???]`-style marker left
//! in the manuscript, so a fast first-draft pass doesn't lose track of the
//! gaps it left behind.
use super::*;
use crate::placeholder_scan::find_placeholders;
impl App {
/// The Placeholders window: one row per marker found, grouped by file in
/// manuscript order. Clicking a row jumps straight to it.
pub(super) fn placeholders_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_placeholders;
// Collect up front (reads the in-memory buffer for the open file,
// disk for the rest), so the window closure only reads.
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let rows: Vec<(String, Vec<crate::placeholder_scan::Placeholder>)> = self
.manuscript_files()
.into_iter()
.filter_map(|(_, name)| {
let raw = if Some(name) == selected_name.as_ref() {
self.document().into_owned()
} else {
std::fs::read_to_string(self.path_for(name)).unwrap_or_default()
};
let found = find_placeholders(&raw);
if found.is_empty() {
None
} else {
Some((name.clone(), found))
}
})
.collect();
let total: usize = rows.iter().map(|(_, m)| m.len()).sum();
let mut jump_to: Option<(String, usize, usize)> = None;
egui::Window::new("Placeholders")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(480.0)
.default_height(420.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(
"Bracketed markers like [TODO], [TK], [FIXME] or [???] left in \
the draft. Click one to jump to it.",
)
.small()
.weak(),
);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if total == 0 {
ui.label("No placeholders found — 🎉");
return;
}
for (name, found) in &rows {
ui.label(egui::RichText::new(name).strong());
for marker in found {
if ui
.link(format!(" line {} · [{}]", marker.line, marker.marker))
.clicked()
{
jump_to = Some((name.clone(), marker.start, marker.end));
}
}
ui.add_space(4.0);
}
});
});
if let Some((name, start, end)) = jump_to {
if let Some(idx) = self.files.iter().position(|f| *f == name) {
self.select(idx);
self.issue_jump = Some((start, end));
}
}
self.show_placeholders = open;
}
}
+39 -28
View File
@@ -61,11 +61,11 @@ impl App {
self.np_status = format!("{} already exists.", parent.join(&name).display());
return;
}
let template = self.config.project_template.clone();
let template = self.config.effective_project_template();
// Check that the template directory exists
if !template.is_dir() {
self.np_status = format!("Template directory {} does not exist.", template.display());
if !template.is_available() {
self.np_status = "The configured template folder has no cookiecutter.json in it."
.to_string();
return;
}
@@ -83,7 +83,7 @@ impl App {
let run_hooks = self.config.project_run_hooks;
let config = crate::project_template::RenderConfig {
template_dir: template,
template,
output_dir: parent,
variables: crate::project_template::TemplateVariables::new(
&name,
@@ -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;
}
@@ -467,6 +487,7 @@ impl App {
let mut template = self.config.project_template.display().to_string();
let r = ui.add(
egui::TextEdit::singleline(&mut template)
.hint_text("blank = use the bundled template")
.desired_width(330.0),
);
if r.changed() {
@@ -477,20 +498,15 @@ impl App {
browse_template = true;
}
});
let template_ok = self
.config
.project_template
.join("cookiecutter.json")
.is_file();
ui.label(
egui::RichText::new(if template_ok {
let status = if self.config.project_template.as_os_str().is_empty() {
"✔ Using the bundled template".to_string()
} else if self.config.project_template.join("cookiecutter.json").is_file() {
"✔ cookiecutter.json found".to_string()
} else {
"✖ no cookiecutter.json in that folder".to_string()
})
.small()
.weak(),
);
"✖ no cookiecutter.json there — falling back to the bundled template"
.to_string()
};
ui.label(egui::RichText::new(status).small().weak());
ui.add_space(6.0);
egui::Grid::new("project_settings_grid")
@@ -514,18 +530,11 @@ 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")
egui::RichText::new(
"✔ Template is compiled into this binary — nothing to install separately",
)
.small()
.weak(),
);
@@ -537,7 +546,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()
{
+177
View File
@@ -0,0 +1,177 @@
//! A low-friction "jot it down" popup that appends a timestamped note to the
//! project's Scratch Pad, so a stray idea while drafting doesn't force a
//! detour into the Story Bible or Outline mid-sentence.
use super::*;
use std::io::Write;
/// Render one captured note as the text appended to `Quick Notes.md`.
pub(super) fn format_capture(text: &str, timestamp: &str) -> String {
format!("\n---\n*{timestamp}*\n\n{}\n", text.trim())
}
/// The current UTC time as `YYYY-MM-DD HH:MM UTC`, with no calendar-library
/// dependency — the same days-since-epoch base `word_log` already uses,
/// turned into a civil date via the standard Hinnant algorithm.
fn now_timestamp() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
let days = secs.div_euclid(86_400);
let secs_of_day = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02} UTC",
secs_of_day / 3600,
(secs_of_day % 3600) / 60
)
}
/// Days-since-epoch to a proleptic Gregorian (year, month, day), per Howard
/// Hinnant's `civil_from_days` (public domain).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
impl App {
/// The project's Scratch Pad folder, looked for the same way
/// [`App::outline_dir`] finds the Outline folder.
fn scratch_pad_dir(&self) -> Option<PathBuf> {
const MAX_UP: usize = 3;
let mut dir = Some(self.workspace());
for _ in 0..=MAX_UP {
let current = dir?;
if let Some(name) = order::child_dir_matching(current, "Scratch Pad") {
return Some(current.join(name));
}
dir = current.parent();
}
None
}
/// Where a captured note is appended: `Quick Notes.md` in the Scratch
/// Pad if one was found, otherwise the workspace root.
fn quick_notes_path(&self) -> PathBuf {
self.scratch_pad_dir()
.unwrap_or_else(|| self.workspace().to_path_buf())
.join("Quick Notes.md")
}
/// Append the pending note to `Quick Notes.md` and clear the popup.
fn capture_quick_note(&mut self) {
let text = self.quick_capture_text.trim().to_string();
if text.is_empty() {
self.show_quick_capture = false;
return;
}
let path = self.quick_notes_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let entry = format_capture(&text, &now_timestamp());
let result = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| file.write_all(entry.as_bytes()));
match result {
Ok(()) => {
self.quick_capture_text.clear();
self.show_quick_capture = false;
self.status = format!("Captured to {}", path.display());
}
Err(e) => {
self.status = format!("Could not save quick note: {e}");
}
}
}
/// The quick-capture popup: one text box, no modal chrome, so it never
/// interrupts a writing session for longer than typing the note takes.
pub(super) fn quick_capture_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_quick_capture;
let mut close = false;
let mut submit = false;
egui::Window::new("Quick note")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(360.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(format!(
"Appended to {}",
self.quick_notes_path().display()
))
.small()
.weak(),
);
ui.add_space(4.0);
let resp = ui.add(
egui::TextEdit::multiline(&mut self.quick_capture_text)
.desired_rows(4)
.desired_width(f32::INFINITY)
.hint_text("What just occurred to you?"),
);
resp.request_focus();
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Capture").clicked() {
submit = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
if submit {
self.capture_quick_note();
} else {
self.show_quick_capture = open && !close;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_capture_wraps_the_note_with_its_timestamp() {
assert_eq!(
format_capture("a stray idea", "2026-09-30 14:05 UTC"),
"\n---\n*2026-09-30 14:05 UTC*\n\na stray idea\n"
);
}
#[test]
fn format_capture_trims_the_note_text() {
assert_eq!(
format_capture(" padded \n", "2026-01-01 00:00 UTC"),
"\n---\n*2026-01-01 00:00 UTC*\n\npadded\n"
);
}
#[test]
fn civil_from_days_matches_known_dates() {
// 1970-01-01 is day 0 by definition.
assert_eq!(civil_from_days(0), (1970, 1, 1));
// 2000-01-01 is a well-known reference point (10957 days later).
assert_eq!(civil_from_days(10_957), (2000, 1, 1));
// 2024-02-29, to confirm leap days land correctly.
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
}
}
+132
View File
@@ -0,0 +1,132 @@
//! A heuristic, nudge-only progress strip for the Snowflake-staged project
//! template (`00-Scratch Pad` … `09-Archive`): how much each stage folder
//! actually holds, so the planning → draft handoff is visible at a glance.
//! Tolerant of custom/non-snowflake templates — a missing stage folder is
//! simply left out, never an error.
use super::*;
/// The bundled snowflake template's stage folders, in order. A project built
/// from a custom template may not have all — or any — of these; each is
/// only shown if it actually exists.
const STAGES: &[&str] = &[
"00-Scratch Pad",
"01-Premise",
"02-Synopsis",
"03-Story-Bible",
"04-Outline",
"05-First Draft",
"06-Revisions",
"07-Final",
"08-Covers",
"09-Archive",
];
/// Word count under which a stage counts as merely "started" rather than
/// "solid" — past template boilerplate, short of a full stage's worth.
const SOLID_THRESHOLD: usize = 200;
/// Total prose words under each stage folder that actually exists in
/// `workspace`, in template order. A missing folder is left out entirely.
pub(super) fn stage_word_counts(workspace: &Path) -> Vec<(String, usize)> {
STAGES
.iter()
.filter_map(|stage| {
let dir = workspace.join(stage);
if !dir.is_dir() {
return None;
}
let words: usize = markdown_files_under(&dir)
.iter()
.map(|path| count_words(&std::fs::read_to_string(path).unwrap_or_default()))
.sum();
Some((stage.to_string(), words))
})
.collect()
}
impl App {
/// A compact "○/◐/● stage name" line per snowflake stage folder found in
/// the workspace. Collapsed by default and draws nothing at all when no
/// stage folder is present, so a custom-template project isn't left with
/// an empty header.
pub(super) fn snowflake_progress_section(&self, ui: &mut egui::Ui) {
let counts = stage_word_counts(self.workspace());
if counts.is_empty() {
return;
}
egui::CollapsingHeader::new("Snowflake progress")
.default_open(false)
.show(ui, |ui| {
for (stage, words) in &counts {
let icon = if *words == 0 {
"○"
} else if *words < SOLID_THRESHOLD {
"◐"
} else {
"●"
};
ui.label(
egui::RichText::new(format!("{icon} {stage}"))
.small()
.weak(),
)
.on_hover_text(format!("{words} words"));
}
});
ui.separator();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"md-manuscript-snowflake-progress-test-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_missing_stage_folder_is_left_out() {
let ws = temp_dir("missing");
assert!(stage_word_counts(&ws).is_empty());
}
#[test]
fn an_empty_stage_folder_counts_zero_words() {
let ws = temp_dir("empty");
fs::create_dir_all(ws.join("01-Premise")).unwrap();
let counts = stage_word_counts(&ws);
assert_eq!(counts, vec![("01-Premise".to_string(), 0)]);
}
#[test]
fn a_populated_stage_folder_sums_words_across_its_files() {
let ws = temp_dir("populated");
let stage = ws.join("05-First Draft");
fs::create_dir_all(stage.join("Act 1")).unwrap();
fs::write(stage.join("Act 1").join("ch01.md"), "one two three").unwrap();
fs::write(stage.join("ch02.md"), "four five").unwrap();
let counts = stage_word_counts(&ws);
assert_eq!(counts, vec![("05-First Draft".to_string(), 5)]);
}
#[test]
fn stages_are_reported_in_template_order_not_filesystem_order() {
let ws = temp_dir("order");
fs::create_dir_all(ws.join("09-Archive")).unwrap();
fs::create_dir_all(ws.join("01-Premise")).unwrap();
let counts = stage_word_counts(&ws);
assert_eq!(
counts.iter().map(|(s, _)| s.as_str()).collect::<Vec<_>>(),
vec!["01-Premise", "09-Archive"]
);
}
}
+41 -1
View File
@@ -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 \
+128
View File
@@ -0,0 +1,128 @@
//! A daily whole-manuscript word goal and a Pomodoro-style writing sprint
//! timer, so momentum from planning carries into actually hitting words on
//! the page. The goal is read fresh against `word_log` each frame, so it
//! resets at the day boundary without any extra bookkeeping.
use super::*;
impl App {
/// Words added to the manuscript since the calendar day started (UTC),
/// even across closing and reopening the app during the same day.
pub(super) fn words_today(&self) -> i64 {
let baseline = self.word_log.baseline_before(crate::word_log::today());
self.manuscript_words() as i64 - baseline as i64
}
/// Time left in the active sprint, if one is running and hasn't finished.
pub(super) fn sprint_remaining(&self) -> Option<Duration> {
let start = self.sprint_start?;
let total = Duration::from_secs(self.config.sprint_minutes as u64 * 60);
total.checked_sub(start.elapsed())
}
/// The Sprint window: today's progress toward the daily goal, plus
/// start/stop controls for a writing sprint.
pub(super) fn sprint_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_sprint;
let mut close = false;
let words_today = self.words_today().max(0) as usize;
let goal = self.config.session_word_goal;
let (frac, color, label) = goal_progress(
crate::preprocess::WordGoal {
min: goal,
max: goal,
},
words_today,
);
let sprint_active = self.sprint_start.is_some();
let remaining = self.sprint_remaining();
let sprint_words = self
.sprint_start
.map(|_| self.manuscript_words().saturating_sub(self.sprint_start_words));
egui::Window::new("Sprint")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(320.0)
.show(ctx, |ui| {
ui.label(egui::RichText::new("Today").strong());
ui.add(egui::ProgressBar::new(frac).fill(color).text(label));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Daily goal:");
let mut goal_val = self.config.session_word_goal;
if ui
.add(egui::DragValue::new(&mut goal_val).range(50..=20_000).speed(10))
.changed()
{
self.config.session_word_goal = goal_val;
self.config.save();
}
ui.label("words");
});
ui.separator();
ui.label(egui::RichText::new("Sprint").strong());
ui.horizontal(|ui| {
ui.label("Length:");
let mut minutes = self.config.sprint_minutes;
if ui
.add(egui::DragValue::new(&mut minutes).range(1..=180).speed(1))
.changed()
{
self.config.sprint_minutes = minutes;
self.config.save();
}
ui.label("minutes");
});
ui.add_space(4.0);
if sprint_active {
match remaining {
Some(left) => {
let secs = left.as_secs();
ui.label(
egui::RichText::new(format!(
"⏱ {:02}:{:02} remaining",
secs / 60,
secs % 60
))
.strong(),
);
// Keep the countdown ticking even with no input.
ctx.request_repaint_after(Duration::from_millis(500));
}
None => {
ui.label(egui::RichText::new("Sprint finished!").strong());
}
}
if let Some(words) = sprint_words {
let minutes_elapsed = self
.sprint_start
.map(|s| (s.elapsed().as_secs_f64() / 60.0).max(1.0 / 60.0))
.unwrap_or(1.0 / 60.0);
ui.label(format!(
"{words} words this sprint · {:.0} words/min",
words as f64 / minutes_elapsed
));
}
if ui.button("Stop").clicked() {
self.sprint_start = None;
}
} else if ui.button("Start sprint").clicked() {
self.sprint_start = Some(Instant::now());
self.sprint_start_words = self.manuscript_words();
}
ui.add_space(4.0);
if ui.button("Close").clicked() {
close = true;
}
});
self.show_sprint = open && !close;
}
}
+477 -31
View File
@@ -4,7 +4,7 @@
use super::*;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// A tree node for the Story Bible tree view.
#[derive(Debug, Clone)]
@@ -159,12 +159,18 @@ impl App {
None
}
/// Render the Story Bible tree recursively.
/// Render the Story Bible tree recursively. `add_requested` collects the
/// directory path a right-click menu's "Add … entry…" was clicked for,
/// and `rename_requested` the file path a "Rename / Move…" was clicked
/// for, so the caller can open the relevant dialog once rendering is
/// done rather than mutating `self` from inside this `&self` walk.
fn render_bible_tree(
&self,
ui: &mut egui::Ui,
nodes: &[BibleTreeNode],
selected_path: &Option<String>,
add_requested: &mut Option<String>,
rename_requested: &mut Option<String>,
) -> Option<String> {
let mut clicked_path: Option<String> = None;
@@ -173,23 +179,41 @@ impl App {
BibleTreeNode::Directory { name, path, children } => {
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
egui::CollapsingHeader::new(name)
let header = egui::CollapsingHeader::new(name)
.default_open(true)
.show(ui, |ui| {
if let Some(child_clicked) = self.render_bible_tree(ui, children, selected_path) {
if let Some(child_clicked) = self.render_bible_tree(
ui,
children,
selected_path,
add_requested,
rename_requested,
) {
clicked_path = Some(child_clicked);
}
});
header.header_response.context_menu(|ui| {
let entry_type = infer_entry_type(path);
let label = format!("➕ Add {entry_type} entry…");
if ui.button(label).clicked() {
*add_requested = Some(path.clone());
ui.close_menu();
}
});
}
BibleTreeNode::File { name, path } => {
let is_selected = selected_path.as_ref().map_or(false, |p| p == path);
if ui
.selectable_label(is_selected, name)
.clicked()
{
let response = ui.selectable_label(is_selected, name);
if response.clicked() {
clicked_path = Some(path.clone());
}
response.context_menu(|ui| {
if ui.button("📝 Rename / Move…").clicked() {
*rename_requested = Some(path.clone());
ui.close_menu();
}
});
}
}
}
@@ -211,6 +235,19 @@ impl App {
.collapsible(false)
.default_width(1000.0)
.default_height(700.0)
// A window only paints as large as its content actually
// measures (`default_width`/`default_height` are no more than
// hints, silently ignored the moment content isn't a single
// top-level ScrollArea), so what actually renders is whatever
// size the tree/editor panes below claim for themselves —
// that's why they're given explicit heights rather than left to
// `auto_shrink(false)` alone, which only stops a pane from
// shrinking *below* whatever height it's handed, and doesn't
// make it claim more. This is also why the resize corner used
// to appear to do nothing: every frame's content re-measure
// snapped the drag straight back to that same small size.
.min_width(1000.0)
.min_height(700.0)
.show(ctx, |ui| {
self.story_bible_editor_contents(ui, &tree);
});
@@ -221,30 +258,59 @@ 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;
// Directory a right-click menu's "Add … entry…" was clicked for, if
// any — applied after the tree finishes rendering, same reason.
let mut add_requested: Option<String> = None;
// File path a right-click menu's "Rename / Move…" was clicked for,
// if any — same deferred-handling reason as `add_requested`.
let mut rename_requested: Option<String> = None;
ui.horizontal(|ui| {
// Left panel: Tree view (1/3 width)
// Left panel: Tree view (1/3 width). The width must be set on
// this panel's own child `Ui`, not the shared horizontal one —
// that had been shrinking the whole row (tree, separator, and
// the editor panel with it) down to a third of the window,
// which is why the editor's heading used to wrap one letter at
// a time.
let tree_width = ui.available_width() / 3.0;
ui.set_width(tree_width);
ui.vertical(|ui| {
ui.set_width(tree_width);
ui.heading("Story Bible");
ui.separator();
// Scroll area for tree
// Scroll area for tree. `auto_shrink(false)` only stops
// this from shrinking *below* whatever height the
// surrounding layout hands it — it doesn't claim more on
// its own, and `max_height` is a ceiling, not a floor (it
// can only shrink, never grow, what the ambient layout
// already offered). `min_scrolled_height` is the one
// that's actually enforced unconditionally, which is what
// establishes — and drives the window's own measured size
// up to — a real, spacious height.
egui::ScrollArea::vertical()
.id_salt("story_bible_editor_tree")
.auto_shrink(false)
.min_scrolled_height(600.0)
.show(ui, |ui| {
if tree.is_empty() {
ui.label("No Story Bible items found");
} else {
// Render tree
if let Some(clicked_path) = self.render_bible_tree(
ui, tree, &self.story_bible_editor_path
ui,
tree,
&self.story_bible_editor_path,
&mut add_requested,
&mut rename_requested,
) {
// Load the clicked file
self.load_story_bible_item(&clicked_path);
@@ -265,25 +331,16 @@ impl App {
}
ui.separator();
// Editor area
egui::ScrollArea::vertical()
.auto_shrink(false)
.show(ui, |ui| {
let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20);
let response = ui.add(text_edit);
// Handle keyboard shortcuts
if response.has_focus() {
// Ctrl+S to save - we'll handle this differently
// since we can't mutate self directly from within the closure
}
});
// Save button
// The Save/Close row lives in its own panel, claimed *before*
// the editor's scroll area: a resizable window fights the
// user's own resize drag when a ScrollArea isn't the last
// thing measured in its content (the cursor shows the resize
// affordance but dragging does nothing) — reserving this
// panel's height up front, the same way file_list.rs's
// `left_pane` does for its own action row, avoids that.
egui::TopBottomPanel::bottom("story_bible_editor_actions")
.frame(egui::Frame::none().inner_margin(egui::Margin::symmetric(0.0, 4.0)))
.show_inside(ui, |ui| {
ui.separator();
ui.horizontal(|ui| {
if ui.button("Save (Ctrl+S)").clicked() {
@@ -297,7 +354,46 @@ impl App {
}
});
});
// Editor area — see the tree pane's comment above on why
// `min_scrolled_height` (not `max_height`) is needed.
egui::ScrollArea::vertical()
.id_salt("story_bible_editor_content")
.auto_shrink(false)
.min_scrolled_height(520.0)
.show(ui, |ui| {
let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content)
.id_salt("story_bible_editor_textedit")
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20);
let response = ui.add(text_edit);
// 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();
});
});
});
self.story_bible_editor_focused = focused;
if let Some(dir) = add_requested {
self.add_story_bible_type = infer_entry_type(&dir);
self.add_story_bible_dir = dir;
self.add_story_bible_name.clear();
self.show_add_story_bible_item = true;
self.add_story_bible_focus = true;
}
if let Some(path) = rename_requested {
self.rename_story_bible_input = strip_md(&path).to_string();
self.rename_story_bible_target = Some(path);
self.show_rename_story_bible = true;
self.rename_story_bible_focus = true;
}
}
/// Load a Story Bible item from the workspace.
@@ -343,6 +439,217 @@ impl App {
}
}
/// The "Add … entry…" dialog opened from a directory's right-click menu.
/// Mirrors `rename_window`'s shape (`src/app/workspace.rs`): a small
/// modal with a focused name field, Enter-to-submit, Create/Cancel.
pub(super) fn add_story_bible_item_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_add_story_bible_item;
let mut submit = false;
let mut cancel = false;
let title = format!("Add {} entry", capitalize(&self.add_story_bible_type));
egui::Window::new(title)
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(320.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(format!("in {}/", self.add_story_bible_dir))
.small()
.weak(),
);
ui.add_space(4.0);
let r = ui.add(
egui::TextEdit::singleline(&mut self.add_story_bible_name)
.desired_width(f32::INFINITY)
.hint_text("e.g. Captain Reyes"),
);
if self.add_story_bible_focus {
r.request_focus();
self.add_story_bible_focus = false;
}
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.label(
egui::RichText::new(format!(
"Starts with id/name/aliases/summary/type: {} frontmatter — \
edit any of it once it's created.",
self.add_story_bible_type
))
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
submit |= ui.button("Create").clicked();
cancel |= ui.button("Cancel").clicked();
});
});
if cancel || !open {
self.show_add_story_bible_item = false;
return;
}
if submit {
if self.create_story_bible_item() {
self.show_add_story_bible_item = false;
} else {
// Rejected (empty name): keep the dialog up, same as Rename.
self.add_story_bible_focus = true;
}
}
}
/// Create the new Story Bible entry `add_story_bible_name` names, inside
/// `add_story_bible_dir`, and load it into the editor. Returns `false`
/// (leaving the dialog open) when there's no usable name yet.
fn create_story_bible_item(&mut self) -> bool {
let name = self.add_story_bible_name.trim();
if name.is_empty() {
self.status = "Name needed".to_string();
return false;
}
let Some(base_dir) = self.find_story_bible_directory() else {
self.status = "Story Bible directory not found".to_string();
return false;
};
let dir = base_dir.join(&self.add_story_bible_dir);
if let Err(e) = fs::create_dir_all(&dir) {
self.status = format!("Could not create {}: {e}", dir.display());
return false;
}
let stem = unique_slug(&dir, &slugify(name));
let rel_path = format!("{}/{stem}.md", self.add_story_bible_dir);
let full_path = base_dir.join(&rel_path);
let content = format!(
"---\n\
id: {stem}\n\
name: \"{name}\"\n\
aliases: []\n\
summary: \"\"\n\
type: {}\n\
status: active\n\
---\n\n\
# {name}\n",
self.add_story_bible_type,
);
match fs::write(&full_path, &content) {
Ok(()) => {
self.status = format!("Created {rel_path}");
self.story_bible_editor_content = content;
self.story_bible_editor_path = Some(rel_path);
self.build_story_bible_index();
true
}
Err(e) => {
self.status = format!("Could not create {rel_path}: {e}");
false
}
}
}
/// The rename/move dialog opened from a file row's context menu. Renaming
/// and moving are the same operation here, same as the manuscript file
/// panel's `rename_window`: editing the leaf renames, editing the folder
/// part moves.
pub(super) fn rename_story_bible_item_window(&mut self, ctx: &egui::Context) {
if self.rename_story_bible_target.is_none() {
// The tree changed underneath us; nothing left to rename.
self.show_rename_story_bible = false;
return;
}
let mut open = self.show_rename_story_bible;
let mut submit = false;
let mut cancel = false;
egui::Window::new("Rename / Move Story Bible item")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(320.0)
.show(ctx, |ui| {
if let Some(ref current) = self.rename_story_bible_target {
ui.label(egui::RichText::new(current.as_str()).small().weak());
}
ui.add_space(4.0);
let r = ui.add(
egui::TextEdit::singleline(&mut self.rename_story_bible_input)
.desired_width(f32::INFINITY)
.hint_text("name, or a path to move it"),
);
if self.rename_story_bible_focus {
r.request_focus();
self.rename_story_bible_focus = false;
}
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.label(
egui::RichText::new(
"The path within the Story Bible folder — edit the folder part \
to move it. The .md extension is added for you.",
)
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
submit |= ui.button("Rename").clicked();
cancel |= ui.button("Cancel").clicked();
});
});
if cancel || !open {
self.show_rename_story_bible = false;
return;
}
if submit {
if self.rename_selected_story_bible_item() {
self.show_rename_story_bible = false;
} else {
self.rename_story_bible_focus = true;
}
}
}
/// Move/rename the Story Bible item named by `rename_story_bible_target`
/// to `rename_story_bible_input`. Returns `false` (leaving the dialog
/// open) when the input isn't usable or the destination is taken.
fn rename_selected_story_bible_item(&mut self) -> bool {
let Some(old_path) = self.rename_story_bible_target.clone() else {
return false;
};
let Some(new_path) = sanitize_rel_path(&self.rename_story_bible_input) else {
self.status = "Enter a new name".to_string();
return false;
};
if new_path == old_path {
// Already what was asked for.
self.rename_story_bible_target = None;
return true;
}
let Some(base_dir) = self.find_story_bible_directory() else {
self.status = "Story Bible directory not found".to_string();
return false;
};
if base_dir.join(&new_path).exists() {
self.status = format!("{new_path} already exists");
return false;
}
match workspace::move_manuscript_file(&base_dir, &old_path, &new_path) {
Ok(()) => {
if self.story_bible_editor_path.as_deref() == Some(old_path.as_str()) {
self.story_bible_editor_path = Some(new_path.clone());
}
self.build_story_bible_index();
self.status = format!("Renamed to {new_path}");
self.rename_story_bible_target = None;
true
}
Err(e) => {
self.status = format!("Rename failed: {e}");
false
}
}
}
/// Open the Story Bible editor.
pub(super) fn open_story_bible_editor(&mut self) {
self.show_story_bible_editor = true;
@@ -373,3 +680,142 @@ impl App {
None
}
}
/// Best-guess entry `type:` for a new Story Bible item, from the directory
/// it's being added to: the last path component, a leading `NN-`/`NN_`/`NN `
/// ordering prefix dropped, underscores turned to spaces, lowercased, and
/// singularized by a simple suffix heuristic (`cities` -> `city`, `Objects`
/// -> `object`). Only a default — nothing stops the file's own `type:`
/// field being edited afterward if the guess is off.
fn infer_entry_type(dir_path: &str) -> String {
let last = dir_path.rsplit('/').next().unwrap_or(dir_path).trim();
let rest = last.trim_start_matches(|c: char| c.is_ascii_digit());
let stripped = match rest.strip_prefix(['-', '_', ' ']) {
Some(after) if !after.trim().is_empty() => after.trim(),
_ => last,
};
let lower = stripped.replace(['_', '-'], " ").to_lowercase();
match lower.strip_suffix("ies") {
Some(stem) => format!("{stem}y"),
None => match lower.strip_suffix('s') {
Some(stem) => stem.to_string(),
None => lower,
},
}
}
/// Capitalize the first character, leaving the rest alone.
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Turn a typed display name into a filename stem: lowercase, runs of
/// anything that isn't a letter or digit collapsed to a single `_`, trimmed
/// of leading/trailing `_`. Falls back to `entry` if nothing alphanumeric
/// survives (an emoji-only name, say), so a file always gets created.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_was_sep = false;
for c in name.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_was_sep = false;
} else if !last_was_sep && !out.is_empty() {
out.push('_');
last_was_sep = true;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"entry".to_string()
} else {
out
}
}
/// `slug`, or `slug_2`/`slug_3`/… if a file by that name already exists in
/// `dir` — the same "find the first free name" shape as the main file
/// panel's `+ New from template`.
fn unique_slug(dir: &Path, slug: &str) -> String {
if !dir.join(format!("{slug}.md")).exists() {
return slug.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{slug}_{n}");
if !dir.join(format!("{candidate}.md")).exists() {
return candidate;
}
n += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infers_type_from_a_top_level_category() {
assert_eq!(infer_entry_type("01-Characters"), "character");
assert_eq!(infer_entry_type("04-Objects"), "object");
assert_eq!(infer_entry_type("05-Relationships"), "relationship");
}
#[test]
fn infers_type_from_the_closest_subfolder() {
assert_eq!(infer_entry_type("01-Characters/main_characters"), "main character");
assert_eq!(infer_entry_type("02-Locations/cities"), "city");
assert_eq!(infer_entry_type("03-Lore/factions"), "faction");
}
#[test]
fn a_name_with_no_trailing_s_is_left_alone() {
assert_eq!(infer_entry_type("06-Timeline"), "timeline");
assert_eq!(infer_entry_type("03-Lore/history"), "history");
}
#[test]
fn capitalize_only_touches_the_first_character() {
assert_eq!(capitalize("character"), "Character");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("main character"), "Main character");
}
#[test]
fn slugify_collapses_punctuation_and_spaces() {
assert_eq!(slugify("Captain Reyes"), "captain_reyes");
assert_eq!(slugify(" Mira Voss "), "mira_voss");
assert_eq!(slugify("O'Brien's Tavern!"), "o_brien_s_tavern");
}
#[test]
fn slugify_falls_back_rather_than_producing_an_empty_name() {
assert_eq!(slugify("***"), "entry");
assert_eq!(slugify(""), "entry");
}
#[test]
fn unique_slug_finds_the_first_free_name() {
let dir = std::env::temp_dir().join(format!(
"md-manuscript-sbe-slug-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira");
fs::write(dir.join("mira.md"), "").unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira_2");
fs::write(dir.join("mira_2.md"), "").unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira_3");
let _ = fs::remove_dir_all(&dir);
}
}
+2 -5
View File
@@ -95,10 +95,7 @@ impl App {
// Find references to this item
let item_refs: Vec<_> = current_refs
.iter()
.filter(|r| {
r.identifier.eq_ignore_ascii_case(&item.id)
|| r.identifier.eq_ignore_ascii_case(&item.name)
})
.filter(|r| r.item_id == item.id)
.collect();
if !item_refs.is_empty() {
@@ -137,6 +134,6 @@ impl App {
/// Jump to a Story Bible reference in the editor.
pub(super) fn jump_to_reference(&mut self, ref_info: &crate::story_bible::StoryBibleReference) {
self.issue_jump = Some((ref_info.column, ref_info.column + ref_info.length));
self.issue_jump = Some((ref_info.start, ref_info.end));
}
}
+97
View File
@@ -0,0 +1,97 @@
//! A single-glance list of every chapter's synopsis (its `# Slug:` line), read
//! straight from the same cached metadata the file-list tooltips use, so a
//! revision pass can scan the whole manuscript's shape without opening each
//! file in turn.
use super::*;
impl App {
/// One row per manuscript file, in export order: (file name, resolved
/// chapter title, frontmatter `# Slug:` synopsis if any). Shared by the
/// Synopsis window and the Outline window's beat-to-chapter matching.
pub(super) fn manuscript_synopsis_rows(&self) -> Vec<(String, String, Option<String>)> {
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
self.manuscript_files()
.into_iter()
.map(|(i, name)| {
let raw = if Some(name) == selected_name.as_ref() {
self.document().into_owned()
} else {
std::fs::read_to_string(self.path_for(name)).unwrap_or_default()
};
let header = crate::preprocess::parse(&raw, &marker);
let title = resolve_chapter_title(
self.titles.get(name).map(String::as_str),
header.title.as_deref(),
i,
pad_width,
);
(name.clone(), title, header.slug)
})
.collect()
}
/// The synopsis window: one row per manuscript file, in export order.
pub(super) fn synopsis_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_synopsis;
let mut jump_to: Option<String> = None;
// Collect everything the window needs up front, so the window's
// closure only reads — no borrow conflicts with the deferred jump.
let rows = self.manuscript_synopsis_rows();
egui::Window::new("Synopsis")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(520.0)
.default_height(480.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(
"Every chapter's # Slug: line, in manuscript order. Click a \
title to open it.",
)
.small()
.weak(),
);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if rows.is_empty() {
ui.label("No manuscript files yet.");
}
for (name, title, slug) in &rows {
ui.horizontal_wrapped(|ui| {
if ui.link(egui::RichText::new(title).strong()).clicked() {
jump_to = Some(name.clone());
}
match slug.as_deref().map(str::trim) {
Some(s) if !s.is_empty() => {
ui.label(s);
}
_ => {
ui.label(
egui::RichText::new("— no synopsis —")
.weak()
.italics(),
);
}
}
});
ui.separator();
}
});
});
if let Some(name) = jump_to {
if let Some(idx) = self.files.iter().position(|f| *f == name) {
self.select(idx);
}
}
self.show_synopsis = open;
}
}
+118 -126
View File
@@ -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);
});
@@ -240,6 +136,38 @@ impl App {
files are not counted)",
);
// A streak of consecutive days with words added,
// persisted in word_log.json so it survives restarts.
let streak = self.word_log.streak(crate::word_log::today());
if streak > 1 {
ui.separator();
ui.label(egui::RichText::new(format!("🔥 {streak}-day streak")).weak())
.on_hover_text(
"Consecutive days the manuscript's word count has grown",
);
}
// A sprint countdown, but only in Distraction-free mode —
// outside it, the full detail lives in the Sprint window.
let distraction_free =
!self.config.show_toolbar && !self.config.show_file_panel;
if distraction_free {
if let Some(left) = self.sprint_remaining() {
let secs = left.as_secs();
ui.separator();
ui.label(
egui::RichText::new(format!(
"⏱ {:02}:{:02}",
secs / 60,
secs % 60
))
.weak(),
)
.on_hover_text("Time left in the current writing sprint");
ctx.request_repaint_after(Duration::from_millis(500));
}
}
// Progress toward the file's Word Count Target, if set.
if let Some((goal, prose)) = goal {
let (frac, color, text) = goal_progress(goal, prose);
@@ -271,7 +199,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 +212,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,9 +270,13 @@ impl App {
ui.close_menu();
self.open_find(true);
}
if ui.button("📖 Story Bible…").clicked() {
if ui
.button("🔎 Search workspace… Ctrl+Shift+F")
.on_hover_text("Find (and optionally replace) across every file")
.clicked()
{
ui.close_menu();
self.open_story_bible_editor();
self.open_workspace_search();
}
if ui.button("📸 Snapshots…").clicked() {
ui.close_menu();
@@ -343,6 +296,30 @@ 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();
}
if ui.button("🧾 Synopsis…").clicked() {
ui.close_menu();
self.show_synopsis = true;
}
if ui.button("⏱ Sprint…").clicked() {
ui.close_menu();
self.show_sprint = true;
}
if ui.button("🔍 Placeholders…").clicked() {
ui.close_menu();
self.show_placeholders = true;
}
if ui.button("🗂 Corkboard…").clicked() {
ui.close_menu();
self.show_corkboard = true;
}
if ui.button("📥 Quick note… Ctrl+Shift+N").clicked() {
ui.close_menu();
self.show_quick_capture = true;
}
ui.separator();
let busy = self.beats_rx.is_some();
if ui
@@ -360,7 +337,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()
@@ -385,6 +362,17 @@ impl App {
ui.close_menu();
self.toggle_distraction_free();
}
if ui
.checkbox(&mut self.config.typewriter_mode, "Typewriter scrolling & focus")
.on_hover_text(
"While Distraction-free mode is on, keep the caret's line \
centered and dim every paragraph but the current one. \
Has no effect otherwise.",
)
.changed()
{
self.config.save();
}
ui.separator();
if ui
.checkbox(&mut self.config.show_preview, "Preview pane")
@@ -432,7 +420,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,12 +432,16 @@ 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;
}
});
ui.menu_button("Help", |ui| {
if ui.button("📖 User Guide").clicked() {
ui.close_menu();
self.show_user_guide = true;
}
if ui.button("📝 Markdown cheatsheet").clicked() {
ui.close_menu();
self.show_cheatsheet = true;
@@ -459,9 +451,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 +658,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 +670,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 {
(
+231 -159
View File
@@ -78,6 +78,7 @@ impl App {
self.manuscript_dir = self.detect_manuscript_dir(&ws);
self.files = order::resolve_order(&ws, &self.config.hidden_folders);
self.titles = order::read_titles(&ws);
self.word_log = crate::word_log::WordLog::load(&ws);
self.wordlist = crate::spell::read_wordlist(&ws);
// Drop overrides for files that no longer exist.
self.titles.retain(|name, _| self.files.contains(name));
@@ -312,6 +313,27 @@ impl App {
.collect()
}
/// Whether `path` sits inside a folder configured to be left out of
/// export (`export_exclude_folders`), matched leniently at any depth —
/// e.g. a "Misc Scenes" dumping ground for scenes not yet placed in the
/// book. Unlike `hidden_folders`, this doesn't affect the file panel,
/// word counts or any other view: the folder stays fully visible and
/// editable, just left out of the exported document.
fn is_export_excluded(&self, path: &str) -> bool {
order::is_within_any(path, &self.config.export_exclude_folders)
}
/// The manuscript's files that actually belong in an export, in order,
/// paired with a fresh contiguous chapter index — so excluded files leave
/// no gaps in the chapter numbering.
pub(super) fn exported_files(&self) -> Vec<(usize, &String)> {
self.files
.iter()
.filter(|path| self.is_manuscript(path) && !self.is_export_excluded(path))
.enumerate()
.collect()
}
/// Read every file and return its current on-disk word count.
pub(super) fn snapshot_counts(&self) -> HashMap<String, usize> {
self.files
@@ -341,37 +363,38 @@ impl App {
}
/// Build the Story Bible index from the Story Bible directory.
///
/// Uses [`Self::find_story_bible_directory`] for the primary lookup —
/// this used to keep its own, separately-hardcoded list of directory
/// names, which had drifted out of sync and no longer included
/// `03-Story-Bible`, the bundled template's actual folder. That silently
/// left the index empty (no highlighting, no hover cards, no hits in the
/// Story Bible window) for every project made from the snowflake
/// template, so there is now exactly one place that name is spelled.
pub(super) fn build_story_bible_index(&mut self) {
use std::path::Path;
let ws = self.config.workspace.clone();
// Look for Story Bible directory - common locations
let story_bible_dirs = [
"03-Characters",
"02-Story Bible",
"Story Bible",
"Characters",
];
for dir_name in story_bible_dirs.iter() {
let dir_path = Path::new(&ws).join(dir_name);
if dir_path.exists() && dir_path.is_dir() {
if let Some(dir_path) = self.find_story_bible_directory() {
self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path);
return;
}
}
// If no specific Story Bible directory found, try to find all character/location/lore dirs
self.story_bible_index = crate::story_bible::StoryBibleIndex::default();
// No single Story Bible directory found — fall back to merging
// whatever scattered character/location/lore folders exist.
let ws = self.config.workspace.clone();
let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"];
for dir_name in possible_dirs.iter() {
let dir_path = Path::new(&ws).join(dir_name);
if dir_path.exists() && dir_path.is_dir() {
let index = crate::story_bible::StoryBibleIndex::build(&dir_path);
// Merge with existing index
for (id, item) in index.items_by_id {
self.story_bible_index.items_by_id.insert(id, item.clone());
}
for (name, item) in index.items_by_name {
self.story_bible_index.items_by_name.insert(name, item);
}
for (alias, item) in index.items_by_alias {
self.story_bible_index.items_by_alias.insert(alias, item);
}
}
}
}
@@ -453,6 +476,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) {
@@ -513,6 +541,11 @@ impl App {
self.spell_menu = None;
self.clear_lt();
self.autocomplete = None;
// Story Bible reference ranges are char offsets into `buffer` too —
// stale ones (from before the header moved in or out) land on
// whatever text now happens to sit `header.len()` characters away
// from the real mention.
self.update_current_references();
}
/// How many header fields are hidden right now, for the toggle's label.
@@ -540,6 +573,12 @@ impl App {
);
self.file_meta.insert(name, meta);
self.merge_field_names_from_buffer();
// Record today's running total for the writing
// streak. Cheap: a handful of small JSON writes,
// no more often than the manuscript itself is saved.
let total = self.manuscript_words();
self.word_log.record(crate::word_log::today(), total);
self.word_log.save(self.workspace());
}
Err(e) => self.status = format!("Save failed: {e}"),
}
@@ -576,6 +615,8 @@ impl App {
self.selected = Some(idx);
self.dirty = false;
self.pending_delete = false;
// A lock earned on one file doesn't carry over to the next.
self.template_unlocked = false;
// Update Story Bible references for the newly selected file
self.update_current_references();
// The rename box holds the whole workspace-relative path, so it doubles
@@ -650,11 +691,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 +717,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 +754,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);
@@ -1139,7 +1042,7 @@ order: {}
fn collect_chapters(&self) -> Vec<Chapter> {
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
self.manuscript_files()
self.exported_files()
.into_iter()
.map(|(i, name)| {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
@@ -1185,7 +1088,7 @@ order: {}
/// below the draft marker is the Fountain.
fn collect_bodies(&self) -> Vec<String> {
let marker = self.config.draft_marker.clone();
self.manuscript_files()
self.exported_files()
.into_iter()
.map(|(_, name)| {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
@@ -1248,7 +1151,7 @@ order: {}
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let mut chapters = Vec::new();
for (i, name) in self.manuscript_files() {
for (i, name) in self.exported_files() {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// Strip comments + the editorial header, and lift out any
// `# Title:` / `# Slug:` metadata.
@@ -1956,6 +1859,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
@@ -1968,10 +1916,118 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
out
}
/// Whether `path` is the bundled scene template (`_template.md`): a reference
/// file writers open to see the expected header fields and copy from by hand,
/// not something the app reads from — "New from template" instead pulls from
/// [`Config::new_file_template`](crate::config::Config::new_file_template).
/// Easy to edit by mistake while skimming it, so the editor locks it against
/// changes until explicitly unlocked — see [`App::editor`](super::App::editor).
pub(super) fn is_template_file(path: &str) -> bool {
base_name(path).eq_ignore_ascii_case("_template.md")
}
#[cfg(test)]
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]
@@ -2053,6 +2109,21 @@ mod tests {
assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n");
}
#[test]
fn is_template_file_matches_only_the_scene_template_by_name() {
assert!(is_template_file("_template.md"));
assert!(is_template_file("05-First Draft/_template.md"));
// Case-insensitive, like the file system it's read from.
assert!(is_template_file("05-First Draft/_TEMPLATE.MD"));
// A chapter folder could have its own copy — still matches by name.
assert!(is_template_file("Act 1/Chapter 2/_template.md"));
// Other underscore-prefixed files (chapter info, notes) aren't it.
assert!(!is_template_file("_chapter.md"));
assert!(!is_template_file("_notes.md"));
assert!(!is_template_file("scene01.md"));
assert!(!is_template_file("template.md"));
}
/// The point of the default template is that the app's own header parser
/// understands what it produces — otherwise the seeded fields are just text.
#[test]
@@ -2284,3 +2355,4 @@ mod tests {
assert_eq!(nearest_listed(&[], Some("05-First Draft"), 0), None);
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Search and replace across every file in the workspace, not just the one
//! open in the editor. Reuses the matching/replacing helpers from `find.rs`
//! rather than reimplementing them.
use super::*;
use super::find::{find_matches, replace_all};
impl App {
/// Open the workspace search window and run the current query, if any.
pub(super) fn open_workspace_search(&mut self) {
self.show_workspace_search = true;
self.refresh_workspace_search();
}
/// Re-run the workspace-wide search: every file in `self.files` is checked
/// (the currently open one via its in-memory buffer, in case it has
/// unsaved edits; every other one is read fresh from disk).
pub(super) fn refresh_workspace_search(&mut self) {
self.ws_results.clear();
if self.ws_query.trim().is_empty() {
self.ws_status = String::new();
return;
}
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let mut total = 0usize;
for name in self.files.clone() {
let text = if Some(&name) == selected_name.as_ref() {
self.document().into_owned()
} else {
match std::fs::read_to_string(self.path_for(&name)) {
Ok(t) => t,
Err(_) => continue,
}
};
let matches = find_matches(&text, &self.ws_query, self.ws_case_sensitive);
if matches.is_empty() {
continue;
}
total += matches.len();
let snippets = matches
.iter()
.take(3)
.map(|&(s, e)| line_snippet(&text, s, e))
.collect();
self.ws_results.push((name, matches.len(), snippets));
}
self.ws_status = if self.ws_results.is_empty() {
"No matches".to_string()
} else {
format!(
"{total} match{} in {} file{}",
if total == 1 { "" } else { "es" },
self.ws_results.len(),
if self.ws_results.len() == 1 { "" } else { "s" }
)
};
}
/// Open `name` and hand off to the ordinary find bar, so stepping through
/// the rest of that file's matches works exactly like Ctrl+F already does.
/// The match position is recomputed against the freshly loaded buffer
/// rather than reusing a byte offset from the search pass, since opening
/// the file can fold its header away and shift every offset within it.
fn jump_to(&mut self, name: &str) {
let Some(idx) = self.files.iter().position(|f| f == name) else {
return;
};
self.select(idx);
self.find_query = self.ws_query.clone();
self.find_case_sensitive = self.ws_case_sensitive;
self.find_active = 0;
self.refresh_find();
self.show_find = true;
self.find_scroll = true;
}
/// Replace every match of `ws_query` with `ws_replace`, in every file the
/// last search found it in. Takes a safety commit first (if the workspace
/// is a git repository) so a bad bulk replace is one `git revert`/checkout
/// away from undone, regardless of whether Auto-Snapshot is enabled.
pub(super) fn workspace_replace_all(&mut self) {
if self.ws_query.is_empty() || self.ws_results.is_empty() {
return;
}
if let Some(root) = self.repo_root.clone() {
self.save_current();
self.persist_order();
self.persist_titles();
let message = format!(
"Snapshot before workspace replace: '{}' -> '{}'",
self.ws_query, self.ws_replace
);
let outcome = Self::git_commit_only(&root, &message);
if !outcome.ok {
self.status = format!(
"Safety snapshot failed, replace cancelled: {}",
outcome.log
);
return;
}
} else {
self.status =
"Not in a git repository — replacing without a safety snapshot".to_string();
}
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let names: Vec<String> = self.ws_results.iter().map(|(n, _, _)| n.clone()).collect();
let mut total = 0usize;
let mut files_changed = 0usize;
for name in names {
let is_open = Some(&name) == selected_name.as_ref();
let text = if is_open {
self.document().into_owned()
} else {
match std::fs::read_to_string(self.path_for(&name)) {
Ok(t) => t,
Err(_) => continue,
}
};
let (new_text, count) =
replace_all(&text, &self.ws_query, &self.ws_replace, self.ws_case_sensitive);
if count == 0 {
continue;
}
total += count;
files_changed += 1;
if is_open {
// `new_text` is the whole document (header included, if any
// was folded); fold it back the same way it was before.
self.header_stash = None;
self.buffer = new_text;
self.dirty = true;
self.apply_header_collapse();
self.clear_lt();
self.save_current();
self.find_needs_refresh = true;
} else if let Err(e) = std::fs::write(self.path_for(&name), &new_text) {
self.status = format!("Failed to write {name}: {e}");
} else {
let meta = FileMeta::from_markdown(&new_text, &self.config.draft_marker);
self.file_meta.insert(name, meta);
}
}
self.status =
format!("Replaced {total} occurrence(s) across {files_changed} file(s)");
self.refresh_workspace_search();
}
/// The workspace search window.
pub(super) fn workspace_search_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_workspace_search;
let mut jump: Option<String> = None;
let mut do_search = false;
let mut do_replace = false;
egui::Window::new("Search workspace")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(560.0)
.default_height(480.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Find:");
let resp = ui.add(
egui::TextEdit::singleline(&mut self.ws_query)
.desired_width(240.0)
.hint_text("search every file"),
);
if resp.changed() || resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
do_search = true;
}
if ui
.checkbox(&mut self.ws_case_sensitive, "Aa")
.on_hover_text("Match case")
.changed()
{
do_search = true;
}
if ui.button("Search").clicked() {
do_search = true;
}
});
ui.horizontal(|ui| {
ui.label("Replace:");
ui.add(
egui::TextEdit::singleline(&mut self.ws_replace)
.desired_width(240.0)
.hint_text("replacement text"),
);
if ui
.add_enabled(!self.ws_results.is_empty(), egui::Button::new("Replace all in workspace"))
.on_hover_text(
"Replaces every match in every file listed below. Takes a \
safety git commit first, if this workspace is a repository.",
)
.clicked()
{
do_replace = true;
}
});
if !self.ws_status.is_empty() {
ui.label(egui::RichText::new(&self.ws_status).weak());
}
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for (name, count, snippets) in &self.ws_results {
ui.horizontal(|ui| {
if ui.link(egui::RichText::new(name).strong()).clicked() {
jump = Some(name.clone());
}
ui.label(
egui::RichText::new(format!(
"({count} match{})",
if *count == 1 { "" } else { "es" }
))
.weak(),
);
});
for snippet in snippets {
ui.label(egui::RichText::new(snippet).monospace().small());
}
ui.separator();
}
});
});
if do_search {
self.refresh_workspace_search();
}
if do_replace {
self.workspace_replace_all();
}
if let Some(name) = jump {
self.jump_to(&name);
}
self.show_workspace_search = open;
}
}
/// A short, trimmed excerpt of the line containing the byte range `s..e`, for
/// display in the workspace search results list.
fn line_snippet(text: &str, s: usize, e: usize) -> String {
let line_start = text[..s].rfind('\n').map_or(0, |i| i + 1);
let line_end = text[e..].find('\n').map_or(text.len(), |i| e + i);
let line = text[line_start..line_end].trim();
const MAX: usize = 120;
if line.chars().count() > MAX {
let truncated: String = line.chars().take(MAX).collect();
format!("{truncated}…")
} else {
line.to_string()
}
}
+154 -49
View File
@@ -85,16 +85,16 @@ 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`).
#[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.
/// An external template to use for **File ▸ New project…** instead of
/// the one bundled into the binary — a folder containing `cookiecutter.json`,
/// for a writer who has customized their own copy. Empty (the default)
/// means "use the bundled template"; see
/// [`crate::project_template::TemplateSource`].
#[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.
pub project_template: PathBuf,
/// 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
@@ -137,6 +137,13 @@ pub struct Config {
/// what the project already has, so `10-Archive` is found and reused.
#[serde(default = "default_archive_folder")]
pub archive_folder: String,
/// Folders left out of every export (ODT, master, screenplay) but not
/// otherwise hidden — still visible, editable and counted everywhere
/// else. Matched leniently at any depth inside the manuscript folder,
/// the same way `hidden_folders` matches. For scenes that are written
/// but not yet placed in the book.
#[serde(default = "default_export_exclude_folders")]
pub export_exclude_folders: Vec<String>,
/// Manuscript title written into exported documents. Blank falls back to the
/// project folder's name.
#[serde(default)]
@@ -170,6 +177,36 @@ pub struct Config {
/// Word count threshold for auto-snapshots.
#[serde(default = "default_auto_snapshot_threshold")]
pub auto_snapshot_threshold: usize,
/// Whether Distraction-free mode also keeps the caret's line centered and
/// dims every paragraph but the current one. Has no effect while
/// Distraction-free mode is off; this is the opt-out for writers who
/// don't want it even then.
#[serde(default = "default_typewriter_mode")]
pub typewriter_mode: bool,
/// Daily whole-manuscript word target the Sprint window tracks progress
/// against. Resets automatically at the day boundary (it's read fresh
/// against `word_log`, not itself day-scoped).
#[serde(default = "default_session_word_goal")]
pub session_word_goal: usize,
/// Length of a writing sprint, in minutes.
#[serde(default = "default_sprint_minutes")]
pub sprint_minutes: u32,
}
/// Typewriter mode is on by default, for anyone turning on Distraction-free
/// mode for the first time.
pub fn default_typewriter_mode() -> bool {
true
}
/// Default daily word-count goal: a modest, sustainable session target.
pub fn default_session_word_goal() -> usize {
500
}
/// Default sprint length: the classic Pomodoro interval.
pub fn default_sprint_minutes() -> u32 {
25
}
/// Default auto-snapshot threshold: 250 words.
@@ -184,36 +221,6 @@ 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.
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() {
// The templates are at the project root level, same as Cargo.toml
// binary is typically at target/release/md-manuscript or target/debug/md-manuscript
// So we need to go up from target/release (or target/debug) to the project root
// Then look for templates/snowflake there
let candidates = [
// Binary in target/release/ -> project root -> templates/snowflake
exe_path.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.join("templates/snowflake")),
// Binary in target/debug/ -> project root -> templates/snowflake
exe_path.parent().and_then(|p| p.parent()).map(|p| p.join("templates/snowflake")),
// Binary in project root (dev) -> templates/snowflake
exe_path.parent().map(|p| p.join("templates/snowflake")),
// Fallback: relative to current working directory
std::env::current_dir().ok().map(|p| p.join("templates/snowflake")),
];
for candidate in candidates.into_iter().flatten() {
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/ exists relative to the executable.");
}
/// Default subfolder opened after generating a project: the snowflake
/// template's drafting folder (05-First Draft), where the chapter files live.
/// Contains merged planning and draft content in a single folder.
@@ -243,6 +250,12 @@ pub fn default_archive_folder() -> String {
"10-Archive".to_string()
}
/// Folders excluded from export by default: the snowflake layout's dumping
/// ground for scenes that are written but not yet placed in the book.
pub fn default_export_exclude_folders() -> Vec<String> {
vec!["Misc Scenes".to_string()]
}
/// Default parent directory for new projects.
pub fn default_projects_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| {
@@ -257,7 +270,10 @@ pub fn default_new_file_template() -> String {
"---",
"title: {{name}}",
"slug: ",
"status: draft",
"pov: ",
"characters: []",
"summary: []",
"word_count_target: ",
"---",
"",
@@ -338,8 +354,7 @@ impl Default for Config {
mistral_model: default_mistral_model(),
mistral_base_url: default_mistral_base_url(),
new_file_template: default_new_file_template(),
project_template: default_project_template(),
cookiecutter_bin: String::new(),
project_template: PathBuf::new(),
project_open_subdir: default_project_open_subdir(),
project_run_hooks: default_project_run_hooks(),
projects_dir: default_projects_dir(),
@@ -351,6 +366,7 @@ impl Default for Config {
gitea_use_ssh: default_gitea_use_ssh(),
hidden_folders: default_hidden_folders(),
archive_folder: default_archive_folder(),
export_exclude_folders: default_export_exclude_folders(),
manuscript_title: String::new(),
manuscript_author: String::new(),
manuscript_contact: String::new(),
@@ -359,6 +375,9 @@ impl Default for Config {
manuscript_screenplay: false,
auto_snapshot_enabled: false,
auto_snapshot_threshold: default_auto_snapshot_threshold(),
typewriter_mode: default_typewriter_mode(),
session_word_goal: default_session_word_goal(),
sprint_minutes: default_sprint_minutes(),
}
}
}
@@ -422,6 +441,20 @@ impl Config {
}
}
/// Which project template to render for **File ▸ New project…**: the
/// configured external override if it's set and valid, otherwise the
/// template bundled into the binary. A saved override pointing at a
/// folder that no longer has `cookiecutter.json` in it (moved, deleted,
/// or — as happened once — never installed) falls back to the bundled
/// template rather than failing project generation outright.
pub fn effective_project_template(&self) -> crate::project_template::TemplateSource {
if self.project_template.join("cookiecutter.json").is_file() {
crate::project_template::TemplateSource::External(self.project_template.clone())
} else {
crate::project_template::TemplateSource::Bundled
}
}
pub fn load() -> Config {
let path = config_path();
match std::fs::read_to_string(&path) {
@@ -538,13 +571,13 @@ mod tests {
assert_eq!(cfg.mistral_api_key, "secret");
// Every new field arrives at its default rather than blank.
assert_eq!(cfg.project_open_subdir, default_project_open_subdir());
assert_eq!(cfg.project_template, default_project_template());
// Empty means "use the bundled template" — see effective_project_template.
assert_eq!(cfg.project_template, PathBuf::new());
assert_eq!(cfg.projects_dir, default_projects_dir());
assert!(cfg.project_run_hooks, "hooks default to running");
// 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
@@ -575,6 +608,57 @@ mod tests {
assert!(!cfg.manuscript_screenplay);
}
/// A config.json written before typewriter mode existed. Same risk as the
/// tests above: a missing `#[serde(default)]` would fail the whole parse
/// and reset every setting the user had.
#[test]
fn config_without_the_typewriter_mode_field_still_loads() {
let old = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"manuscript_author": "A. Writer"
}"####;
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
assert_eq!(cfg.manuscript_author, "A. Writer");
// On by default, so an existing Distraction-free habit gains the
// feature rather than needing to discover and enable it.
assert!(cfg.typewriter_mode);
}
#[test]
fn config_without_the_sprint_fields_still_loads() {
let old = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt"
}"####;
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
assert_eq!(cfg.session_word_goal, 500);
assert_eq!(cfg.sprint_minutes, 25);
}
#[test]
fn config_without_the_export_exclude_field_still_loads() {
let old = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt"
}"####;
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
assert_eq!(cfg.export_exclude_folders, vec!["Misc Scenes".to_string()]);
}
/// An explicit opt-out must survive a reload, the same way `gitea_use_ssh`
/// being turned off has to (both default to `true`).
#[test]
fn turning_off_typewriter_mode_survives_a_reload() {
let saved = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"typewriter_mode": false
}"####;
let cfg: Config = serde_json::from_str(saved).expect("config must parse");
assert!(!cfg.typewriter_mode);
}
/// A saved subfolder choice must win over the built-in default, so changing
/// the default never silently moves an existing user's projects.
#[test]
@@ -588,12 +672,33 @@ mod tests {
assert_eq!(cfg.project_open_subdir, "05-Plot");
}
/// The old exe-path/cwd filesystem search for the bundled template is
/// gone — it's compiled into the binary now (see
/// `project_template::BUNDLED_SNOWFLAKE`, and
/// `bundled_template_contains_the_expected_files` in that module's
/// tests). What's left to check here is that an *unset* config correctly
/// resolves to the bundled source rather than an external one.
#[test]
fn bundled_template_is_found_via_exe_path() {
let path = default_project_template();
// The function should find the bundled template
// Verify it returns a valid path that contains cookiecutter.json
assert!(path.join("cookiecutter.json").is_file(),
"Template path {} should contain cookiecutter.json", path.display());
fn an_unset_project_template_resolves_to_the_bundled_source() {
let cfg = Config::default();
assert!(matches!(
cfg.effective_project_template(),
crate::project_template::TemplateSource::Bundled
));
}
/// A saved override pointing at a folder with no `cookiecutter.json` in
/// it — moved, deleted, or never installed, like the bug that prompted
/// this — must fall back to the bundled template, not error out.
#[test]
fn a_broken_external_override_falls_back_to_bundled() {
let cfg = Config {
project_template: PathBuf::from("/nonexistent/template/path"),
..Config::default()
};
assert!(matches!(
cfg.effective_project_template(),
crate::project_template::TemplateSource::Bundled
));
}
}
-411
View File
@@ -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);
}
}
+4 -3
View File
@@ -192,7 +192,8 @@ fn current_branch(workspace: &Path) -> String {
.unwrap_or_else(|| "main".to_string())
}
/// The unified diff of one file against the last commit, as raw diff text.
/// The unified diff of one file against an arbitrary commit-ish (`HEAD`, a
/// commit hash, a tag...) — the last commit, or an older Auto-Snapshot.
///
/// Revising is comparing: what changed in this scene since the last time it was
/// good? The repository already holds that answer, so the app asks git rather
@@ -200,10 +201,10 @@ fn current_branch(workspace: &Path) -> String {
///
/// `rel` is relative to the *workspace*, which may sit below the repository
/// root, so the path is resolved through the work tree before being handed over.
pub fn diff_file(workspace: &Path, rel: &str) -> Result<String, String> {
pub fn diff_file_at(workspace: &Path, rel: &str, base: &str) -> Result<String, String> {
let (ok, out) = run_git(
workspace,
&["--no-pager", "diff", "--no-color", "HEAD", "--", rel],
&["--no-pager", "diff", "--no-color", base, "--", rel],
);
if !ok {
// A repository with no commits yet has no HEAD to compare against.
+393 -5
View File
@@ -1,9 +1,10 @@
//! In-app **Markdown cheatsheet** window: a quick reference for the markdown
//! md-manuscript understands, plus the manuscript-specific conventions (YAML frontmatter
//! metadata and comments) applied at export time.
//! In-app documentation, reached from the **Help** menu: a **Markdown
//! cheatsheet** (the markdown/frontmatter syntax md-manuscript understands)
//! and a broader **User Guide** (what every window, menu item and setting
//! actually does).
//!
//! Pure rendering — the caller owns the `open` flag so the window's ✕ works via
//! [`egui::Window::open`].
//! Pure rendering — the caller owns the `open` flag so each window's ✕ works
//! via [`egui::Window::open`].
use eframe::egui;
@@ -19,6 +20,375 @@ pub fn cheatsheet_window(ctx: &egui::Context, open: &mut bool) {
});
}
/// The User Guide window: what every window, menu item and setting does.
/// Complements the Markdown Cheatsheet, which covers syntax rather than
/// features.
pub fn user_guide_window(ctx: &egui::Context, open: &mut bool) {
egui::Window::new("📖 User Guide")
.open(open)
.resizable(true)
.default_width(720.0)
.default_height(680.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, user_guide_body);
});
}
fn user_guide_body(ui: &mut egui::Ui) {
// More breathing room than egui's default item spacing — scoped to this
// `Ui` (and so inherited by every child it lays out below), not to the
// shared `section`/`note`/`body`/`entries` helpers themselves, so the
// Markdown Cheatsheet's own window is unaffected.
ui.spacing_mut().item_spacing.y = 12.0;
intro(
ui,
"What every window, menu item and setting does. For markdown and \
frontmatter syntax, see Help ▸ 📝 Markdown Cheatsheet instead.",
);
guide_getting_started(ui);
guide_organizing(ui);
guide_focus_and_pace(ui);
guide_planning_tools(ui);
guide_staying_on_track(ui);
guide_revision_and_safety(ui);
guide_exporting(ui);
guide_projects_and_templates(ui);
guide_proofing(ui);
guide_plot_beats(ui);
guide_settings_reference(ui);
}
fn guide_getting_started(ui: &mut egui::Ui) {
section(ui, "Getting started");
body(
ui,
"Set the Workspace path in the top bar (📂 to browse) and press Open — \
the folder is created if it doesn't exist. + New (type a name, \
optionally with folders like part-1/ch-01) or + New Scene creates a \
file; edit on the right and Ctrl+S (or Save) writes it to disk.",
);
note(
ui,
"Opening a folder that contains a 05-First Draft-style subfolder puts \
the app in Project mode: that subfolder's files are the book \
(ordered, numbered, exported); everything else — characters, \
outline, scratch pad — is reference material, reached through the \
Tools menu windows below rather than listed in the file panel. View \
▸ Show reference files lists it anyway, dimmed.",
);
body(
ui,
"File ▸ ⟳ Sync (git) commits everything, pulls --rebase, and pushes. \
Init git first if the workspace isn't a repository yet; Settings ▸ \
Git remote… attaches it to a server (and can create the repository \
on Gitea for you, given a server URL, user and token).",
);
}
fn guide_organizing(ui: &mut egui::Ui) {
section(ui, "Organizing the manuscript");
body(
ui,
"The file list is a tree: give a new file a path (part-2/ch-03) to \
nest it, and drag its ⠿ handle onto a folder — or among its files — \
to move it. Drop on “↥ drop here for the top level” to pull it back \
out. A folder exists only as long as it holds a file.",
);
note(
ui,
"Order is set by dragging and saved in order.json. A folder's files \
always stay together as a block, positioned by whichever of them \
sorts earliest — and the export is the tree read top to bottom, so \
this is also the chapter order.",
);
body(
ui,
"The Chapter title field overrides a file's auto-derived title (a \
Title: header field, or its position — “3.” — if there is none). \
Right-click a file for Rename…, Copy path, 🗄 Archive and 🗑 Delete….",
);
}
fn guide_focus_and_pace(ui: &mut egui::Ui) {
section(ui, "Focus & pace");
entries(
ui,
&[
(
"Ctrl/Cmd + D",
"Distraction-free: folds the toolbar and file list away, leaving \
the page. Again to bring them back.",
),
(
"View ▸ Typewriter scrolling & focus",
"While Distraction-free is on: keeps the caret's line centred \
and dims every paragraph but the current one.",
),
(
"Zoom / Contrast sliders",
"Above the editor — scale the editor text, or brighten it \
against the theme background.",
),
],
);
body(
ui,
"Tools ▸ ⏱ Sprint… tracks a daily whole-manuscript word goal \
(default 500, resets at the day boundary) with a progress bar, plus \
a start/stop Pomodoro-style sprint timer with a live words/minute \
readout. In Distraction-free mode, an active sprint's countdown \
also shows compactly in the status bar.",
);
note(
ui,
"The status bar's 🔥 N-day streak counts consecutive calendar days \
the manuscript's total word count grew — a day with no growth, or a \
skipped day, breaks it. It's recorded automatically on every save.",
);
}
fn guide_planning_tools(ui: &mut egui::Ui) {
section(ui, "Planning & reference");
body(
ui,
"These read a project's other parts — everything that isn't the \
draft itself.",
);
entries(
ui,
&[
(
"Tools ▸ ☑ Outline…",
"Reads the outline scaffold's HTML-comment beats and shows a \
progress bar per section, nested by heading. A beat with a \
matching chapter name gets a “→ open draft” link.",
),
(
"Tools ▸ 👥 Characters…",
"Every character sheet, which scenes they appear in (by \
matching Characters: fields), and ▦ Rebuild grid to \
regenerate character_grid.md.",
),
(
"Tools ▸ 📖 Story Bible…",
"Indexes character/location/lore sheets and auto-links \
mentions of them in the draft — no markup needed, though \
[[Name]] and @Name both work too. Hover a highlighted name \
for a summary card; click its window entries to jump to \
references.",
),
(
"Tools ▸ 📖 Story Bible editor…",
"A breakout window for editing Story Bible sheets directly: \
the full tree on the left, a text editor on the right. \
Saving refreshes the index automatically.",
),
(
"Tools ▸ 🗂 Corkboard…",
"One card per chapter — title and synopsis — drag to reorder \
the manuscript itself, the same reorder a file-panel drag \
does.",
),
(
"Tools ▸ 🧾 Synopsis…",
"Every chapter's Slug: line in one scrollable list, for \
scanning the whole manuscript's shape at a glance.",
),
(
"Snowflake progress",
"Collapsible strip at the top of the file panel: a ○/◐/● \
reading per snowflake-template stage folder, by word count. \
A nudge, not a gate — folders the current template doesn't \
have are simply left out.",
),
],
);
}
fn guide_staying_on_track(ui: &mut egui::Ui) {
section(ui, "Staying organized while drafting");
entries(
ui,
&[
(
"Tools ▸ 🔍 Placeholders…",
"Finds bracketed markers left mid-draft — [TODO], [TK], \
[FIXME], [XXX], [NOTE], [???] — grouped by file, click to \
jump to one.",
),
(
"Tools ▸ 📥 Quick note… (Ctrl+Shift+N)",
"A small popup for jotting a stray idea without losing your \
place — appended, timestamped, to Quick Notes.md in the \
project's Scratch Pad folder.",
),
(
"Misc Scenes",
"A dumping ground for scenes not yet placed (under \
05-First Draft in the bundled template) — visible, editable \
and word-counted like any other folder, but left out of \
every export. Configurable as export_exclude_folders in \
config.json.",
),
],
);
}
fn guide_revision_and_safety(ui: &mut egui::Ui) {
section(ui, "Revision & safety");
entries(
ui,
&[
(
"Edit ▸ 📸 Snapshots…",
"Automatic git commits at a configurable word-count interval \
(default every 250 words), plus a manual snapshot button and \
one-click rollback to any of them.",
),
(
"Edit ▸ ⇄ Changes since last commit…",
"Diffs the open file against its last committed version, \
coloured the way git colours a diff.",
),
(
"Edit ▸ 🔎 Search workspace… (Ctrl+Shift+F)",
"Find — and optionally replace — across every file in the \
workspace at once, not just the open one. A safety commit is \
made automatically before a workspace-wide replace.",
),
(
"Status: header field",
"Gives a file a badge in the file panel (initials, so \
“Structural Edit” shows as “SE”) and a Status filter above \
the list, to narrow it to one revision stage.",
),
(
"🗄 Archive",
"Moves a file into the project's archive folder (kept out of \
the file panel and the export) without deleting it — for \
material you've moved past, not material you want gone.",
),
],
);
}
fn guide_exporting(ui: &mut egui::Ui) {
section(ui, "Exporting");
body(
ui,
"File ▸ Export ODT concatenates every manuscript file into one \
document, each starting a new page, headed by its chapter title and \
optional Slug: caption. File ▸ Export chapters + master (.odm) \
instead writes one .odt per chapter plus a linking master — treat \
the master as a starting point to relink, not the assembled book.",
);
note(
ui,
"Settings ▸ Manuscript details…'s Standard manuscript format lays \
the export out the way a submission is expected to arrive (12pt \
Courier, double-spaced, page headers, chapters a third of the way \
down their page); Begin exports with a title page adds a title page \
in front, built from the title/author/contact fields there.",
);
body(
ui,
"Tick Screenplay (Fountain) in Manuscript details to make the \
project a screenplay instead: each file's body is read as Fountain, \
and Export ODT becomes Export PDF — a conventionally paginated \
screenplay, joined into one continuous script across files.",
);
}
fn guide_projects_and_templates(ui: &mut egui::Ui) {
section(ui, "Projects & templates");
body(
ui,
"File ▸ ✨ New project… scaffolds a whole project from a template \
(the bundled “snowflake” layout — premise, synopsis, story bible, \
outline, first draft, revisions, final — by default, compiled into \
the binary so nothing needs installing alongside it) and opens it \
straight into its drafting folder.",
);
note(
ui,
"Settings ▸ New-project settings… points Template at an external \
folder with its own cookiecutter.json to use instead — blank uses \
the bundled one, and a broken override falls back to it rather than \
failing project creation. Manuscript folder sets which project \
subfolder is the book (default 05-First Draft).",
);
body(
ui,
"Tick Publish to Gitea after creation there (with a server URL, user \
and token filled in) to have a new project pushed to a fresh Gitea \
repository automatically; Settings ▸ Git remote… does the same for \
a workspace you already have.",
);
}
fn guide_proofing(ui: &mut egui::Ui) {
section(ui, "Proofing");
body(
ui,
"Spelling is checked live, offline, as you type — no server, no \
network. Right-click an underlined word for corrections, or to add \
it to Settings ▸ Word list…, which also offers 👤 Add character \
names to pull in your whole cast at once. Canadian and British \
English are built in; pick one from the Spelling row's dropdown.",
);
note(
ui,
"Settings ▸ LanguageTool… connects a grammar-and-style server (a \
local instance keeps your manuscript off the network). Press ✓ \
Check to run it on the open file — its results take over the \
underlines until you next edit, then the offline spell checker \
resumes.",
);
}
fn guide_plot_beats(ui: &mut egui::Ui) {
section(ui, "Plot beats (Mistral)");
body(
ui,
"Tools ▸ ✨ Generate plot beats (Mistral)… turns a rough proposal \
file (characters, setting, a loose summary) into three-act plot \
beats, using the Mistral API. Set an API key first in Settings ▸ \
Mistral…. Results open in a window to edit, save as a new file, or \
copy.",
);
note(
ui,
"If the proposal's acts are already partly beaten out (three or more \
numbered beats under a heading counts as drafted), the generator \
writes only the missing acts instead of a whole sheet, treating the \
drafted ones as canon to work backwards from.",
);
}
fn guide_settings_reference(ui: &mut egui::Ui) {
section(ui, "Settings, at a glance");
entries(
ui,
&[
("LanguageTool…", "Grammar/spelling server connection (scheme, host, port, token, language)."),
("Mistral…", "API key, model and base URL for plot-beat generation."),
("New-file template…", "The frontmatter + New from template seeds a file with."),
("New-project settings…", "Template override, manuscript folder name, Gitea publishing defaults."),
("Git remote…", "Attach (or create on Gitea) the current workspace's remote."),
("Manuscript details…", "Title/author/contact, standard format, title page, screenplay toggle."),
("Spelling…", "The word list — names and invented terms the checker accepts."),
],
);
note(
ui,
"All settings live in ~/.config/md-manuscript/config.json and apply \
machine-wide; per-workspace data (order, titles, word list, word \
log) is committed alongside the manuscript in the workspace itself.",
);
}
fn cheatsheet_body(ui: &mut egui::Ui) {
intro(
ui,
@@ -235,3 +605,21 @@ fn syntax(ui: &mut egui::Ui, id: &str, rows: &[(&str, &str)]) {
}
});
}
/// A `label`/description list, stacked (label, then a wrapped paragraph)
/// rather than side by side — for descriptions too long to sit in
/// `syntax`'s two-column grid without either overflowing or forcing the
/// whole window wider to avoid wrapping, since a `Grid` sizes each column to
/// its widest cell instead of wrapping. Relies on the caller's own
/// `item_spacing` for the gap between rows, rather than hand-adding one here
/// on top of it.
fn entries(ui: &mut egui::Ui, rows: &[(&str, &str)]) {
for (label, desc) in rows {
ui.label(
egui::RichText::new(*label)
.monospace()
.color(egui::Color32::from_rgb(120, 170, 120)),
);
ui.label(*desc);
}
}
+2 -1
View File
@@ -5,7 +5,6 @@
mod app;
mod characters;
mod config;
mod cookiecutter;
mod fountain;
mod gitea;
mod gitsync;
@@ -16,11 +15,13 @@ mod odt;
mod outline;
mod order;
mod pdf;
mod placeholder_scan;
mod preprocess;
mod project_template;
mod screenplay;
mod spell;
mod story_bible;
mod word_log;
use eframe::egui;
+36
View File
@@ -146,6 +146,17 @@ pub fn is_within(path: &str, dir: &str) -> bool {
dir.is_empty() || path.starts_with(dir) && path[dir.len()..].starts_with('/')
}
/// Whether `path` sits inside a folder named like one of `names`, matched
/// leniently by [`dir_matches`], at *any* depth — not just as a direct
/// child. Used for folders (like a "Misc Scenes" dumping ground) that can
/// live nested anywhere under the manuscript folder.
pub fn is_within_any(path: &str, names: &[String]) -> bool {
path.split('/')
.rev()
.skip(1) // the file name itself
.any(|component| names.iter().any(|n| dir_matches(component, n)))
}
/// Turn text typed into the new-file or rename box into a safe workspace-relative
/// markdown path. `\\` becomes `/`, blank components and `.`/`..` are dropped so
/// nothing can point outside the workspace, and a `.md` extension is ensured.
@@ -592,6 +603,31 @@ mod tests {
assert!(dir_matches("2061", "2061"));
}
#[test]
fn is_within_any_matches_at_any_depth() {
let names = vec!["Misc Scenes".to_string()];
assert!(is_within_any(
"05-First Draft/Misc Scenes/scene01.md",
&names
));
// Nested deeper than a direct child still counts.
assert!(is_within_any(
"05-First Draft/Misc Scenes/sub/scene01.md",
&names
));
// Matched leniently, same as `dir_matches`.
assert!(is_within_any("Misc Scenes/scene01.md", &names));
}
#[test]
fn is_within_any_does_not_match_the_file_itself_or_unrelated_folders() {
let names = vec!["Misc Scenes".to_string()];
// A file merely *named* "Misc Scenes.md" is not a folder match.
assert!(!is_within_any("05-First Draft/Misc Scenes.md", &names));
assert!(!is_within_any("05-First Draft/ch-01.md", &names));
assert!(!is_within_any("scene01.md", &names));
}
#[test]
fn hidden_folders_are_kept_out_of_the_scan() {
let ws = scratch(
+146
View File
@@ -0,0 +1,146 @@
//! Finds bracketed placeholder markers (`[TODO]`, `[TK]`, `[???]`, …) dropped
//! into prose while drafting fast, so nothing gets forgotten before a draft
//! is called done. No `regex` dependency: a hand-rolled bracket scan is
//! plenty for a short, fixed set of markers.
/// A bracketed placeholder found in a file's text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placeholder {
/// Byte offset of the `[` in the text.
pub start: usize,
/// Byte offset just past the `]` in the text.
pub end: usize,
/// 1-indexed line number the marker appears on.
pub line: usize,
/// The marker text as written, e.g. `TODO` or `???`.
pub marker: String,
}
/// Marker keywords recognised inside brackets, matched case-insensitively.
const MARKER_WORDS: &[&str] = &["TODO", "TK", "FIXME", "XXX", "NOTE"];
/// Longest a bracketed span can be and still be considered a placeholder
/// rather than, say, a Markdown link's link text.
const MAX_SPAN_LEN: usize = 40;
/// Find every placeholder marker in `text`.
pub fn find_placeholders(text: &str) -> Vec<Placeholder> {
let mut out = Vec::new();
let mut line = 1;
let mut last_end = 0;
let bytes = text.as_bytes();
let mut search_from = 0;
while let Some(rel_start) = text[search_from..].find('[') {
let start = search_from + rel_start;
let Some(rel_close) = text[start + 1..].find(']') else {
break;
};
let close = start + 1 + rel_close;
let inner = &text[start + 1..close];
// Count newlines passed since the last marker (or the start) to
// keep `line` in step without rescanning from the top each time.
line += text[last_end..start].matches('\n').count();
last_end = start;
if inner.len() <= MAX_SPAN_LEN && !inner.contains('\n') && is_marker(inner) {
out.push(Placeholder {
start,
end: close + 1,
line,
marker: inner.trim().to_string(),
});
}
search_from = close + 1;
if search_from > bytes.len() {
break;
}
}
out
}
/// Whether bracketed inner text `s` reads as a placeholder: an exact (case
/// -insensitive) marker keyword, or entirely `?` characters.
fn is_marker(s: &str) -> bool {
let trimmed = s.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.chars().all(|c| c == '?') {
return true;
}
MARKER_WORDS
.iter()
.any(|m| m.eq_ignore_ascii_case(trimmed))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_each_known_marker_word() {
for word in MARKER_WORDS {
let text = format!("Some prose [{word}] continues.");
let found = find_placeholders(&text);
assert_eq!(found.len(), 1, "expected a match for [{word}]");
assert_eq!(found[0].marker, *word);
}
}
#[test]
fn finds_question_mark_placeholders() {
let found = find_placeholders("She said [???] and left.");
assert_eq!(found.len(), 1);
assert_eq!(found[0].marker, "???");
}
#[test]
fn marker_matching_is_case_insensitive() {
let found = find_placeholders("[todo] fix this scene");
assert_eq!(found.len(), 1);
assert_eq!(found[0].marker, "todo");
}
#[test]
fn a_markdown_link_is_not_a_false_positive() {
let found = find_placeholders("See [a markdown link](https://example.com) for more.");
assert!(found.is_empty());
}
#[test]
fn an_ordinary_bracketed_aside_is_not_a_false_positive() {
let found = find_placeholders("He shrugged [as he always did when cornered].");
assert!(found.is_empty());
}
#[test]
fn line_numbers_are_tracked_across_multiple_lines() {
let text = "Line one.\nLine two has [TK] in it.\nLine three has [FIXME] too.";
let found = find_placeholders(text);
assert_eq!(found.len(), 2);
assert_eq!(found[0].line, 2);
assert_eq!(found[1].line, 3);
}
#[test]
fn byte_offsets_cover_the_brackets_themselves() {
let text = "abc [TODO] def";
let found = find_placeholders(text);
assert_eq!(found.len(), 1);
assert_eq!(&text[found[0].start..found[0].end], "[TODO]");
}
#[test]
fn an_unterminated_bracket_does_not_hang_or_panic() {
let found = find_placeholders("This has an [unterminated bracket");
assert!(found.is_empty());
}
#[test]
fn no_brackets_at_all_yields_nothing() {
assert!(find_placeholders("Plain prose, nothing to see here.").is_empty());
}
}
+412 -77
View File
@@ -33,6 +33,12 @@ pub struct Header {
pub pov: Option<String>,
/// Word-count target from a `Word Count Target:` line, if present and parsable.
pub goal: Option<WordGoal>,
/// Bullet points from a frontmatter `summary` list, in writing order. Each
/// item keeps its own punctuation intact — unlike a generic [`field`]'s
/// comma-joined value, nothing here is lost if a bullet has a comma of its
/// own. Empty when there's no frontmatter or no `summary` key (YAML-only;
/// the legacy marker-based header has no list syntax to borrow).
pub summary: Vec<String>,
/// The prose body, with comments, the header block, and metadata lines removed.
pub body: String,
}
@@ -113,35 +119,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 +161,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 +193,167 @@ 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;
let mut lines = frontmatter.lines();
while let Some(line) = lines.next() {
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 = join_flow_continuation(value, &mut lines);
let value = flow_list_items(&value);
collecting = value.is_empty();
out.push(Field { name, value });
}
}
out
}
/// Net count of un-quoted `[`/`]` in `s`, positive while a flow sequence begun
/// on an earlier line is still open.
fn bracket_depth(s: &str) -> i32 {
let mut depth = 0;
let mut in_quote = false;
let mut quote_char = '\0';
for c in s.chars() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
}
c2 if in_quote && c2 == quote_char => in_quote = false,
'[' if !in_quote => depth += 1,
']' if !in_quote => depth -= 1,
_ => {}
}
}
depth
}
/// Pull in whatever further lines a flow sequence needs to balance its
/// brackets, so `key: [\n "a",\n "b",\n]` — how a multi-line list is often
/// hand-written, one bullet per line — reads the same as `key: [a, b]` on one
/// line. A value that was already balanced (or never opened a list) passes
/// through untouched, and an unterminated one is returned as far as it got.
fn join_flow_continuation<'a>(mut value: String, lines: &mut impl Iterator<Item = &'a str>) -> String {
while bracket_depth(&value) > 0 {
match lines.next() {
Some(cont) => {
value.push(' ');
value.push_str(cont.trim());
}
None => break,
}
}
value
}
/// Split a flow sequence's inner text on commas that are not inside a quoted
/// item, so `"a, b"` keeps its own comma rather than being cut in two by it —
/// unlike a bare name, prose can't be trusted to never contain one. A trailing
/// comma (the only way to end a list spread one item per line, and a habit
/// many writers bring from other languages) leaves no empty item behind.
fn split_flow_items(inner: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = '\0';
for c in inner.chars() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
current.push(c);
}
c2 if in_quote && c2 == quote_char => {
in_quote = false;
current.push(c2);
}
',' if !in_quote => {
items.push(current.trim().to_string());
current.clear();
}
_ => current.push(c),
}
}
if !current.trim().is_empty() {
items.push(current.trim().to_string());
}
items.into_iter().map(|s| unquote(&s)).filter(|s| !s.is_empty()).collect()
}
/// 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();
};
split_flow_items(inner).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 +453,30 @@ 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(),
summary: summary_items(frontmatter),
body: body.trim().to_string(),
};
}
@@ -383,24 +534,48 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: slug.filter(|s| !s.is_empty()),
pov: pov.filter(|s| !s.is_empty()),
goal,
summary: Vec::new(),
body: body_lines.join("\n").trim().to_string(),
}
}
/// 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) {
/// Every bullet of a frontmatter `summary` list, in writing order — see
/// [`Header::summary`]. Supports both YAML list forms, including a flow
/// sequence spread over several lines (`summary: [\n "...",\n]`).
fn summary_items(frontmatter: &str) -> Vec<String> {
let mut lines = frontmatter.lines().peekable();
while let Some(line) = lines.next() {
if line.trim() == "---" {
return Some(i + 1);
continue;
}
if block_list_item(line).is_some() {
continue; // belongs to some other key's block list; keep scanning
}
None
let Some((key, value)) = parse_yaml_field(line) else {
continue;
};
if !key.eq_ignore_ascii_case("summary") {
continue;
}
if value.is_empty() {
// A bare `summary:` opens a block list on the following lines.
let mut items = Vec::new();
while let Some(next) = lines.peek().copied() {
let Some(item) = block_list_item(next) else {
break;
};
items.push(item);
lines.next();
}
return items;
}
let value = join_flow_continuation(value, &mut lines);
let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
return Vec::new(); // a scalar, not a list
};
return split_flow_items(inner);
}
Vec::new()
}
/// Parse a YAML field line like `key: value` or `key: "value"`.
@@ -480,14 +655,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 +765,171 @@ 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 a_flow_list_item_keeps_its_own_comma() {
// `flow_list_items`' old naive `split(',')` cut "a, b" into two items;
// prose can't promise to never contain a comma the way a name can.
let md = r#"---
summary: ["First, then second.", "Third."]
---
Prose.
"#;
assert_eq!(parse(md, "").summary, ["First, then second.", "Third."]);
}
#[test]
fn a_summary_list_spread_over_several_lines_is_read_whole() {
// The shape a hand-written multi-line flow sequence actually takes:
// the opening `[` ends its line, one bullet per line after that, the
// closing `]` alone on the last. Each line used to vanish (no colon,
// so `parse_yaml_field` ignored it, and it doesn't start with `-` so
// `block_list_item` ignored it too) and `summary` read as a literal "[".
let md = "---\n\
title: \"A Title\"\n\
summary: [\n\
\t\"Jonah is waiting for the bus.\",\n\
\t\"We see the loneliness of his existence.\",\n\
\t\"Jonah goes to the lab and does a favour for a co-worker\",\n\
]\n\
pov: \"Jonah\"\n\
---\n\n\
Prose.\n";
let h = parse(md, "");
assert_eq!(
h.summary,
[
"Jonah is waiting for the bus.",
"We see the loneliness of his existence.",
"Jonah goes to the lab and does a favour for a co-worker",
]
);
// A key after the multi-line list is still read correctly.
assert_eq!(h.pov.as_deref(), Some("Jonah"));
assert_eq!(h.title.as_deref(), Some("A Title"));
}
#[test]
fn a_block_style_summary_list_is_read_too() {
let md = "---\nsummary:\n - First point.\n - \"Second, with a comma.\"\nsetting: kitchen\n---\n\nProse.\n";
let h = parse(md, "");
assert_eq!(h.summary, ["First point.", "Second, with a comma."]);
}
#[test]
fn summary_is_empty_when_absent_or_a_bare_empty_list() {
assert!(parse("---\ntitle: x\n---\n\nProse.\n", "").summary.is_empty());
assert!(parse("---\nsummary: []\n---\n\nProse.\n", "").summary.is_empty());
// Legacy marker-based header: no list syntax exists there.
assert!(parse("# Title: x\n### Rough Draft:\n\nProse.", "### Rough Draft:")
.summary
.is_empty());
}
#[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";
+128 -16
View File
@@ -3,12 +3,81 @@
//! This module replaces the cookiecutter dependency by providing native Rust
//! templating for project generation. It uses MiniJinja which is Jinja2-compatible,
//! so existing cookiecutter templates (like snowflake) work with minimal changes.
//!
//! The default (bundled) template's files are compiled into the binary via
//! [`BUNDLED_SNOWFLAKE`], so **File ▸ New project…** works out of the box —
//! no `templates/` folder has to be installed beside the executable. A writer
//! can still point at a folder of their own (`Settings ▸ New project… ▸
//! Template`) to use a customized copy instead; see [`TemplateSource`].
use include_dir::{include_dir, Dir};
use minijinja::Environment;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::fs;
/// The bundled "snowflake" project template, compiled into the binary so
/// **File ▸ New project…** never depends on a `templates/` folder installed
/// beside the executable.
static BUNDLED_SNOWFLAKE: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/snowflake");
/// Where a project template comes from: the one compiled into the binary, or
/// a folder on disk the writer has pointed **Settings ▸ New project… ▸
/// Template** at (to use a customized copy of their own).
#[derive(Debug, Clone)]
pub enum TemplateSource {
Bundled,
External(PathBuf),
}
impl TemplateSource {
/// Whether this source is actually usable. `Bundled` always is — it's
/// compiled in; `External` needs a real `cookiecutter.json` at the path.
pub fn is_available(&self) -> bool {
match self {
TemplateSource::Bundled => true,
TemplateSource::External(path) => path.join("cookiecutter.json").is_file(),
}
}
}
/// Removes its directory on drop, so the bundled template's temp extraction
/// is cleaned up whether `render_template` returns `Ok` or `Err`.
struct TempExtraction {
dir: PathBuf,
}
impl Drop for TempExtraction {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
/// Write the bundled template out to a fresh temp directory, so the rest of
/// the render pipeline can treat it exactly like an on-disk template.
fn extract_bundled() -> Result<(PathBuf, TempExtraction), String> {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"md-manuscript-template-{}-{unique}",
std::process::id()
));
// `Dir::extract` fails if any file already exists, so clear any stale
// leftovers (e.g. from a previous crash) first.
let _ = fs::remove_dir_all(&dir);
// `extract` creates *sub*directories as it walks in, but never the root
// path itself — with top-level files in the template (cookiecutter.json,
// README.md), it would otherwise try to write into a directory that
// doesn't exist yet.
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
BUNDLED_SNOWFLAKE
.extract(&dir)
.map_err(|e| format!("Failed to extract the bundled template: {e}"))?;
Ok((dir.clone(), TempExtraction { dir }))
}
/// Template variables passed to MiniJinja.
#[derive(Debug, Clone)]
pub struct TemplateVariables {
@@ -55,8 +124,9 @@ impl TemplateVariables {
/// Configuration for template rendering.
pub struct RenderConfig {
/// Path to the template directory (contains cookiecutter.json and template folder)
pub template_dir: PathBuf,
/// Where the template (with its cookiecutter.json and template folder)
/// comes from.
pub template: TemplateSource,
/// Output directory where the project will be created
pub output_dir: PathBuf,
/// Template variables
@@ -77,12 +147,24 @@ pub type RenderResult = Result<PathBuf, String>;
/// a template expression (e.g., `{{ cookiecutter.project_name }}`) which
/// becomes the project root after rendering.
pub fn render_template(config: &RenderConfig) -> RenderResult {
// Resolve the template to a real directory on disk: an external override
// is used as-is, while the bundled template is extracted to a fresh temp
// directory first. `_guard` cleans that temp directory up when this
// function returns, however it returns.
let (template_dir, _guard): (PathBuf, Option<TempExtraction>) = match &config.template {
TemplateSource::External(path) => (path.clone(), None),
TemplateSource::Bundled => {
let (dir, guard) = extract_bundled()?;
(dir, Some(guard))
}
};
// Verify the template directory has a cookiecutter.json
let cookiecutter_json = config.template_dir.join("cookiecutter.json");
let cookiecutter_json = template_dir.join("cookiecutter.json");
if !cookiecutter_json.is_file() {
return Err(format!(
"No cookiecutter.json in {} — that folder is not a template.",
config.template_dir.display()
template_dir.display()
));
}
@@ -130,8 +212,8 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
// Find the template project directory (the one with the templated name)
// This is typically named something like "{{ cookiecutter.project_name }}"
let template_entries = fs::read_dir(&config.template_dir)
.map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?;
let template_entries = fs::read_dir(&template_dir)
.map_err(|e| format!("Cannot read template directory {}: {}", template_dir.display(), e))?;
let mut template_project_dir: Option<PathBuf> = None;
for entry in template_entries {
@@ -149,7 +231,7 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
let template_project_dir = template_project_dir.ok_or(format!(
"No template project directory found in {}. Expected a directory with a templated name like '{{{{ cookiecutter.project_name }}}}'.",
config.template_dir.display()
template_dir.display()
))?;
// Render the project directory name
@@ -307,22 +389,19 @@ mod tests {
assert_eq!(name, "test project");
}
/// Integration test: render the snowflake template using MiniJinja.
/// This tests that the templating works end-to-end with a real template.
/// Integration test: render the bundled snowflake template using
/// MiniJinja. This tests that the templating works end-to-end, and that
/// the compiled-in template is actually extracted and used correctly —
/// there's no "skip if the template isn't installed" escape hatch
/// anymore, since it's always available now.
#[test]
fn test_render_snowflake_template() {
let template_dir = crate::config::default_project_template();
if !template_dir.join("cookiecutter.json").is_file() {
eprintln!("skipping: no snowflake template at {}", template_dir.display());
return;
}
let out = std::env::temp_dir().join("md_manuscript_minijinja_test");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let config = RenderConfig {
template_dir,
template: TemplateSource::Bundled,
output_dir: out.clone(),
variables: TemplateVariables::new(
"MiniJinja Test Project",
@@ -369,4 +448,37 @@ mod tests {
// Clean up
let _ = std::fs::remove_dir_all(&out);
}
/// The embed itself has to actually hold the template — a hermetic check
/// (no filesystem, no temp directory) that what's compiled into the
/// binary is a real, usable snowflake template.
#[test]
fn bundled_template_contains_the_expected_files() {
assert!(
BUNDLED_SNOWFLAKE.get_file("cookiecutter.json").is_some(),
"bundled template is missing cookiecutter.json"
);
let project_dir = BUNDLED_SNOWFLAKE
.dirs()
.find(|d| {
d.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("{{"))
})
.expect("bundled template is missing its `{{ cookiecutter.project_name }}` folder");
let subdir = crate::config::default_project_open_subdir();
assert!(
project_dir.get_dir(project_dir.path().join(&subdir)).is_some(),
"bundled template is missing its {subdir} folder"
);
}
#[test]
fn extracting_the_bundled_template_cleans_up_after_itself() {
let (dir, guard) = extract_bundled().expect("extraction should succeed");
assert!(dir.join("cookiecutter.json").is_file());
drop(guard);
assert!(!dir.exists(), "temp extraction should be removed on drop");
}
}
+348 -59
View File
@@ -14,11 +14,13 @@ pub struct StoryBibleReference {
pub file_path: String,
/// The line number where the reference appears (1-indexed).
pub line_number: usize,
/// The character position in the line.
pub column: usize,
/// The length of the reference text.
pub length: usize,
/// The matched identifier.
/// Byte offset of the reference's start in the whole buffer.
pub start: usize,
/// Byte offset just past the reference's end in the whole buffer.
pub end: usize,
/// The id of the Story Bible item this reference resolves to.
pub item_id: String,
/// The matched text, exactly as it appears in the draft.
pub identifier: String,
}
@@ -37,6 +39,9 @@ pub struct StoryBibleItem {
pub aliases: Vec<String>,
/// Line in the file where the name/ID is defined.
pub definition_line: usize,
/// A short summary for the hover card, from a `summary:`/`description:`
/// frontmatter field. `None` when the sheet doesn't have one.
pub summary: Option<String>,
}
/// Index of all Story Bible items for quick lookup.
@@ -176,6 +181,7 @@ fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryB
item_type,
aliases: Vec::new(),
definition_line: 0,
summary: None,
})
}
@@ -195,14 +201,22 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
}
let mut name: Option<String> = None;
let mut title: Option<String> = None;
let mut id: Option<String> = None;
let mut item_type: Option<String> = None;
let mut aliases: Vec<String> = Vec::new();
let mut summary: Option<String> = None;
let mut description: Option<String> = None;
for line in &lines[start + 1..end] {
let trimmed = line.trim();
if trimmed.starts_with("title:") {
name = Some(trimmed["title:".len()..].trim().trim_matches('"').to_string());
// `name:` is what every bundled Story Bible template actually
// writes; `title:` is accepted too since a scene/draft file's own
// frontmatter uses that word and a sheet may borrow it by habit.
if trimmed.starts_with("name:") {
name = Some(trimmed["name:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("title:") {
title = Some(trimmed["title:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("id:") {
id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("type:") {
@@ -211,11 +225,23 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
// Parse aliases as a YAML list: aliases: ["Alias 1", "Alias 2"] or aliases: Alias1, Alias2
let value = trimmed["aliases:".len()..].trim();
aliases = parse_aliases(value);
} else if trimmed.starts_with("summary:") {
let value = trimmed["summary:".len()..].trim().trim_matches('"').trim();
if !value.is_empty() {
summary = Some(value.to_string());
}
} else if trimmed.starts_with("description:") {
let value = trimmed["description:".len()..].trim().trim_matches('"').trim();
if !value.is_empty() {
description = Some(value.to_string());
}
}
}
// `summary:` wins if a sheet somehow has both.
let summary = summary.or(description);
// Use name or filename as fallback
let name = name.unwrap_or_else(|| {
// Use name, then title, then the filename as fallback.
let name = name.or(title).unwrap_or_else(|| {
Path::new(relative_path)
.file_stem()
.and_then(|s| s.to_str())
@@ -255,6 +281,7 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
item_type,
aliases,
definition_line: start + 1,
summary,
})
}
@@ -324,72 +351,141 @@ fn parse_aliases(value: &str) -> Vec<String> {
.collect()
}
/// Find all Story Bible references in a text.
/// Find all Story Bible references in `text`, absolute byte offsets into the
/// whole buffer (not per-line), so callers can map them straight onto a galley.
///
/// Looks for patterns like `[[Character Name]]` or `@CharacterName`.
/// Two kinds, in priority order — an overlap always keeps the higher one:
///
/// 1. **Explicit markup** — `[[Name]]` or `@Name`, resolved by ID, name or
/// alias. The author asked for this link by hand, so it always wins.
/// 2. **Bare mentions** — a known item's name or an alias, spelled exactly as
/// it appears in its Story Bible sheet, found in ordinary prose with no
/// markup at all. This is what makes the linking automatic: plan the cast
/// once in the Story Bible, then just write their names.
///
/// Bare matching is case-sensitive and requires the candidate to start with
/// an uppercase letter — names are written that way both in the sheet and as
/// proper nouns in prose, and that's what keeps a character named "Hope" or
/// "Will" from lighting up on every unrelated use of the word. A phrase under
/// three characters is skipped too, since a two-letter alias (initials, say)
/// matches too much of everyday prose to be worth auto-detecting; it still
/// works through the explicit `[[...]]`/`@` markup above.
pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec<StoryBibleReference> {
let mut references = Vec::new();
// (start, end, item_id, identifier) — accepted spans, explicit first.
let mut accepted: Vec<(usize, usize, String, String)> = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
let line_num = line_idx + 1;
let mut pos = 0;
while pos < line.len() {
// Check for wiki-style links: [[...]]
if line[pos..].starts_with("[[") {
let end_pos = line[pos + 2..]
.find("]]")
.map(|p| pos + 2 + p + 2);
if let Some(end) = end_pos {
let identifier = line[pos + 2..end - 2].trim().to_string();
// Check if this matches any Story Bible item (by ID, name, or alias)
if index.find_by_id(&identifier).is_some()
|| index.find_by_name(&identifier).is_some()
|| index.find_by_alias(&identifier).is_some()
{
references.push(StoryBibleReference {
file_path: String::new(), // Will be set by caller
line_number: line_num,
column: pos,
length: end - pos,
identifier: identifier.clone(),
});
// 1. Explicit [[...]] and @Name markup. Walked one *char* at a time
// (manuscript prose is full of accented letters and em dashes, and a
// byte-at-a-time walk can land mid-character and panic on the next slice).
let mut pos = 0usize;
while pos < text.len() {
if text[pos..].starts_with("[[") {
// `]]` only counts on the same line — an unterminated `[[` should
// not swallow the rest of the document looking for a closer.
let line_end = text[pos..]
.find('\n')
.map(|o| pos + o)
.unwrap_or(text.len());
if let Some(rel_end) = text[pos + 2..line_end].find("]]") {
let end = pos + 2 + rel_end + 2;
let identifier = text[pos + 2..end - 2].trim().to_string();
if let Some(item) = resolve(index, &identifier) {
accepted.push((pos, end, item.id.clone(), identifier));
}
pos = end;
continue;
}
}
// Check for mention-style: @Name
if line[pos..].starts_with('@') && pos + 1 < line.len() {
// Extract word after @ - stop at space or other non-word characters
let word_end = line[pos + 1..]
} else if text.as_bytes()[pos] == b'@' && pos + 1 < text.len() {
let rest = &text[pos + 1..];
let word_end = pos
+ 1
+ rest
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.map(|p| pos + 1 + p)
.unwrap_or(line.len());
let identifier = line[pos + 1..word_end].trim().to_string();
if !identifier.is_empty()
&& (index.find_by_id(&identifier).is_some()
|| index.find_by_name(&identifier).is_some()
|| index.find_by_alias(&identifier).is_some())
{
references.push(StoryBibleReference {
file_path: String::new(),
line_number: line_num,
column: pos,
length: word_end - pos,
identifier,
});
.unwrap_or(rest.len());
let identifier = text[pos + 1..word_end].to_string();
if !identifier.is_empty() {
if let Some(item) = resolve(index, &identifier) {
accepted.push((pos, word_end, item.id.clone(), identifier));
}
}
pos = word_end;
continue;
}
pos += text[pos..].chars().next().map(char::len_utf8).unwrap_or(1);
}
pos += 1;
// 2. Bare mentions. Every item's name and aliases, longest phrase first
// so "Bixby Reynolds" claims its span before "Bixby" alone gets a chance.
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (phrase, item_id)
for item in index.items_by_id.values() {
candidates.push((item.name.as_str(), item.id.as_str()));
for alias in &item.aliases {
candidates.push((alias.as_str(), item.id.as_str()));
}
}
candidates.retain(|(phrase, _)| {
phrase.len() >= 3 && phrase.chars().next().is_some_and(char::is_uppercase)
});
candidates.sort_by_key(|(phrase, _)| std::cmp::Reverse(phrase.len()));
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
for (phrase, item_id) in candidates {
for (start, matched) in text.match_indices(phrase) {
let end = start + matched.len();
let before_ok = text[..start]
.chars()
.next_back()
.map_or(true, |c| !is_word_char(c));
let after_ok = text[end..].chars().next().map_or(true, |c| !is_word_char(c));
if !before_ok || !after_ok {
continue;
}
if accepted.iter().any(|&(s, e, ..)| start < e && end > s) {
continue; // overlaps an explicit link or a longer bare match
}
accepted.push((start, end, item_id.to_string(), phrase.to_string()));
}
}
references
accepted.sort_by_key(|&(start, ..)| start);
let line_starts = line_start_offsets(text);
accepted
.into_iter()
.map(|(start, end, item_id, identifier)| StoryBibleReference {
file_path: String::new(), // set by the caller
line_number: line_number_at(&line_starts, start),
start,
end,
item_id,
identifier,
})
.collect()
}
/// Look up an identifier by ID, then name, then alias — whichever matches
/// first, the same fallback order the old separate call sites each used.
fn resolve<'a>(index: &'a StoryBibleIndex, identifier: &str) -> Option<&'a StoryBibleItem> {
index
.find_by_id(identifier)
.or_else(|| index.find_by_name(identifier))
.or_else(|| index.find_by_alias(identifier))
}
/// Byte offset of the start of each line, `text[0]` included, in order.
fn line_start_offsets(text: &str) -> Vec<usize> {
std::iter::once(0)
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
.collect()
}
/// 1-indexed line number containing `byte_offset`, from a table built by
/// [`line_start_offsets`].
fn line_number_at(line_starts: &[usize], byte_offset: usize) -> usize {
match line_starts.binary_search(&byte_offset) {
Ok(i) => i + 1,
Err(i) => i,
}
}
/// Generate a markdown link to a Story Bible item.
@@ -398,6 +494,25 @@ pub fn link_to_item(item: &StoryBibleItem, display_text: Option<&str>) -> String
format!("[{}]({})", text, item.path)
}
/// A short, reader-friendly label for an item's type, for a hover card or
/// list row — "Person" for a character sheet, "Place" for a location, and
/// Title Case of anything else (lore, object, theme, …), since those don't
/// reduce to one generic word.
pub fn type_label(item_type: &str) -> String {
match item_type.to_lowercase().as_str() {
"character" | "characters" => "Person".to_string(),
"location" | "locations" => "Place".to_string(),
"object" | "objects" => "Thing".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => "Entry".to_string(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -417,6 +532,71 @@ Some content here."#;
assert_eq!(item.item_type, "character");
}
#[test]
fn frontmatter_reads_name_the_way_every_bundled_template_actually_writes_it() {
// Every sheet the snowflake template ships — character, location,
// lore, object — writes `name:`, never `title:`. A regression check
// for the mismatch that made every item's name fall back to a
// filename-derived guess instead.
let content = r#"---
id: bixby_reynolds
name: Bixby Reynolds
type: protagonist
---
# Bixby Reynolds"#;
let item = extract_from_frontmatter("main_characters/01_bixby.md", content).unwrap();
assert_eq!(item.name, "Bixby Reynolds");
}
#[test]
fn frontmatter_prefers_name_over_title_when_a_sheet_somehow_has_both() {
let content = r#"---
name: Real Name
title: Old Spelling
---
"#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.name, "Real Name");
}
#[test]
fn frontmatter_summary_feeds_the_hover_card() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
summary: A retired locksmith with one unfinished job.
---
Some content here."#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(
item.summary.as_deref(),
Some("A retired locksmith with one unfinished job.")
);
}
#[test]
fn frontmatter_falls_back_to_description_and_prefers_summary() {
let description_only = r#"---
title: "Jane"
description: Appears in Act 2.
---
"#;
let item = extract_from_frontmatter("test.md", description_only).unwrap();
assert_eq!(item.summary.as_deref(), Some("Appears in Act 2."));
let both = r#"---
title: "Jane"
summary: The short version.
description: The long version.
---
"#;
let item = extract_from_frontmatter("test.md", both).unwrap();
assert_eq!(item.summary.as_deref(), Some("The short version."));
}
#[test]
fn test_find_references_wiki_links() {
let index = {
@@ -430,6 +610,7 @@ Some content here."#;
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
},
);
idx.items_by_name.insert(
@@ -441,6 +622,7 @@ Some content here."#;
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
},
);
idx
@@ -462,6 +644,7 @@ Some content here."#;
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
};
idx.items_by_name.insert("jane".to_string(), item.clone());
idx.items_by_id.insert("jane".to_string(), item);
@@ -483,6 +666,7 @@ Some content here."#;
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0,
summary: None,
};
index.items_by_id.insert("john_smith".to_string(), item.clone());
index.items_by_name.insert("john smith".to_string(), item.clone());
@@ -505,6 +689,7 @@ Some content here."#;
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0,
summary: None,
};
index.items_by_name.insert("john smith".to_string(), item);
@@ -527,6 +712,7 @@ Some content here."#;
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string()],
definition_line: 0,
summary: None,
};
index.items_by_name.insert("john smith".to_string(), item.clone());
index.items_by_id.insert("john_smith".to_string(), item.clone());
@@ -541,6 +727,109 @@ Some content here."#;
assert_eq!(refs[0].identifier, "Johnny");
}
/// A one-item index for the bare-mention tests below, keyed by name and
/// (if given) aliases — the same shape a real `StoryBibleIndex::build`
/// produces for a single character sheet.
fn index_with(id: &str, name: &str, aliases: &[&str]) -> StoryBibleIndex {
let item = StoryBibleItem {
path: format!("Characters/{id}.md"),
name: name.to_string(),
id: id.to_string(),
item_type: "character".to_string(),
aliases: aliases.iter().map(|a| a.to_string()).collect(),
definition_line: 0,
summary: None,
};
let mut index = StoryBibleIndex::new();
index.items_by_id.insert(id.to_string(), item.clone());
index.items_by_name.insert(name.to_lowercase(), item.clone());
for alias in aliases {
index.items_by_alias.insert(alias.to_lowercase(), item.clone());
}
index
}
#[test]
fn a_bare_mention_is_found_without_any_markup() {
let index = index_with("bixby", "Bixby", &[]);
let refs = find_references("Bixby walked into the room.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Bixby");
assert_eq!(refs[0].item_id, "bixby");
assert_eq!(&"Bixby walked into the room."[refs[0].start..refs[0].end], "Bixby");
}
#[test]
fn a_bare_mention_matches_against_a_real_disk_built_index() {
// The hand-built `index_with` above exercises the matching rules;
// this instead goes through `StoryBibleIndex::build`, the same path
// a real project's character sheet takes, as a check that nothing
// is lost crossing that boundary (frontmatter parsing, `id`
// lowercasing, and so on).
let dir = std::env::temp_dir().join(format!(
"md_manuscript_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("protagonist.md"),
"---\nid: protagonist\nname: Protagonist\nsummary: \"hero\"\nslug: protagonist\ntype: protagonist\n---\n\n# Protagonist\n",
)
.unwrap();
let index = StoryBibleIndex::build(&dir);
let refs = find_references("Protagonist stood at the door.", &index);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].item_id, "protagonist");
}
#[test]
fn a_bare_mention_respects_word_boundaries() {
let index = index_with("bixby", "Bixby", &[]);
let refs = find_references("Bixbys and Bixbyish things aren't Bixby.", &index);
assert_eq!(refs.len(), 1, "only the standalone \"Bixby\" should match");
assert_eq!(refs[0].start, 34);
}
#[test]
fn a_bare_mention_is_case_sensitive_to_dodge_common_words() {
// A character named "Hope" must not light up every ordinary use of
// the word "hope" — only the capitalized, name-spelled uses.
let index = index_with("hope", "Hope", &[]);
let refs = find_references("She had no hope left, until Hope arrived.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].start, 28);
}
#[test]
fn the_longest_bare_mention_wins_an_overlap() {
let index = index_with("bixby_reynolds", "Bixby Reynolds", &["Bixby"]);
let refs = find_references("Bixby Reynolds frowned.", &index);
assert_eq!(refs.len(), 1, "the full name should claim the span, not just \"Bixby\"");
assert_eq!(refs[0].identifier, "Bixby Reynolds");
}
#[test]
fn a_bare_mention_does_not_duplicate_an_explicit_link() {
let index = index_with("bixby", "Bixby", &[]);
let refs = find_references("Meet [[Bixby]] properly.", &index);
assert_eq!(refs.len(), 1, "the explicit [[...]] span should win, not add a second match");
}
#[test]
fn reference_offsets_are_absolute_into_the_whole_buffer() {
// A regression check for the bug where offsets were relative to each
// line, so anything past line 1 highlighted (and jumped to) the wrong
// place in the editor.
let index = index_with("bixby", "Bixby", &[]);
let text = "First line.\nSecond line has Bixby in it.";
let refs = find_references(text, &index);
assert_eq!(refs.len(), 1);
assert_eq!(&text[refs[0].start..refs[0].end], "Bixby");
assert_eq!(refs[0].line_number, 2);
}
#[test]
fn test_parse_aliases_yaml_list() {
let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#);
+196
View File
@@ -0,0 +1,196 @@
//! A per-day log of the manuscript's total word count, kept in the workspace
//! (like `order.json`/`titles.json`) so writing progress — and a streak —
//! survives between launches and syncs with git for free.
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
/// Name of the file (inside the workspace) that stores the word log.
pub const WORD_LOG_FILE: &str = "word_log.json";
/// One calendar day's recorded state: the manuscript's total prose word
/// count as of the last save made that day.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DayEntry {
/// Days since the Unix epoch (UTC) — so entries compare and subtract
/// without any calendar arithmetic.
pub day: i64,
/// Total prose word count across the manuscript as of the last save on
/// this day.
pub total_words: usize,
}
/// The word log: one entry per day that had at least one save, oldest first.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WordLog {
pub days: Vec<DayEntry>,
}
/// Today, as days since the Unix epoch (UTC).
pub fn today() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| (d.as_secs() / 86_400) as i64)
.unwrap_or(0)
}
impl WordLog {
/// Load the log from the workspace. Missing or unparsable -> empty log,
/// the same tolerant fallback `Config::load` uses — a corrupt or
/// not-yet-existing log must never block opening the workspace.
pub fn load(workspace: &Path) -> WordLog {
let path = workspace.join(WORD_LOG_FILE);
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => WordLog::default(),
}
}
/// Persist the log as JSON into the workspace.
pub fn save(&self, workspace: &Path) {
let path = workspace.join(WORD_LOG_FILE);
if let Ok(text) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(path, text);
}
}
/// Record `total_words` as `day`'s state, replacing any earlier record
/// for the same day — later saves on a day simply overwrite it, so the
/// log only ever holds each day's final total.
pub fn record(&mut self, day: i64, total_words: usize) {
if let Some(entry) = self.days.iter_mut().find(|d| d.day == day) {
entry.total_words = total_words;
} else {
self.days.push(DayEntry { day, total_words });
self.days.sort_by_key(|d| d.day);
}
}
/// The manuscript's word-count baseline for `day`: the most recent
/// *earlier* recorded total (whichever day that was, not necessarily
/// `day - 1`), or 0 if there is none yet. Also the "words written today"
/// baseline the Sprint window subtracts from the live count.
pub fn baseline_before(&self, day: i64) -> usize {
self.days
.iter()
.filter(|d| d.day < day)
.map(|d| d.total_words)
.max()
.unwrap_or(0)
}
/// Whether `day` grew the manuscript: its recorded total is higher than
/// [`Self::baseline_before`] it.
fn wrote(&self, day: i64) -> bool {
let Some(today_total) = self.days.iter().find(|d| d.day == day).map(|d| d.total_words)
else {
return false;
};
today_total > self.baseline_before(day)
}
/// The current writing streak as of `today`: consecutive calendar days,
/// ending today (or yesterday, if nothing has been written yet today),
/// each of which grew the manuscript. A missing day breaks the chain —
/// today itself still counts if it grew the manuscript, but the streak
/// doesn't reach back past the gap.
pub fn streak(&self, today: i64) -> usize {
let mut day = if self.days.iter().any(|d| d.day == today) {
today
} else {
today - 1
};
let mut count = 0usize;
loop {
if !self.wrote(day) {
break;
}
count += 1;
if !self.days.iter().any(|d| d.day == day - 1) {
break;
}
day -= 1;
}
count
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn streak_counts_consecutive_growing_days() {
let mut log = WordLog::default();
log.record(10, 1000);
log.record(11, 1500);
log.record(12, 1800);
assert_eq!(log.streak(12), 3);
// Nothing written yet today (13) — still counts through yesterday.
assert_eq!(log.streak(13), 3);
}
#[test]
fn a_gap_day_breaks_the_streak() {
let mut log = WordLog::default();
log.record(10, 1000);
log.record(11, 1500);
// 12 is skipped entirely.
log.record(13, 1800);
assert_eq!(log.streak(13), 1);
}
#[test]
fn a_flat_or_shrinking_day_breaks_the_streak() {
let mut log = WordLog::default();
log.record(10, 1000);
log.record(11, 1000); // no change
log.record(12, 1400);
assert_eq!(log.streak(12), 1);
}
#[test]
fn the_very_first_day_counts_from_a_zero_baseline() {
let mut log = WordLog::default();
log.record(10, 500);
assert_eq!(log.streak(10), 1);
assert_eq!(log.streak(11), 1);
}
#[test]
fn later_saves_the_same_day_overwrite_rather_than_duplicate() {
let mut log = WordLog::default();
log.record(10, 500);
log.record(10, 900);
assert_eq!(log.days.len(), 1);
assert_eq!(log.days[0].total_words, 900);
}
#[test]
fn baseline_before_is_zero_with_no_earlier_days() {
let mut log = WordLog::default();
log.record(10, 500);
assert_eq!(log.baseline_before(10), 0);
}
#[test]
fn baseline_before_is_the_most_recent_earlier_total() {
let mut log = WordLog::default();
log.record(10, 500);
log.record(11, 900);
assert_eq!(log.baseline_before(12), 900);
// A gap before `day` still finds the most recent earlier entry.
assert_eq!(log.baseline_before(20), 900);
}
#[test]
fn load_of_a_missing_file_is_an_empty_log_not_an_error() {
let dir = std::env::temp_dir().join(format!(
"md-manuscript-wordlog-test-{}",
std::process::id()
));
let log = WordLog::load(&dir);
assert!(log.days.is_empty());
}
}
@@ -1,6 +1,10 @@
---
id: character_id
name: Character Name
# One line the app can show in a hover card when this name appears in a
# draft — not a substitute for the full sheet below, just enough to
# place them at a glance.
summary: ""
slug: character-name
pronunciation: ""
type: protagonist
@@ -1,6 +1,7 @@
---
id: protagonist
name: Protagonist
summary: ""
slug: protagonist
pronunciation: ""
type: protagonist
@@ -1,6 +1,7 @@
---
id: antagonist
name: Antagonist
summary: ""
slug: antagonist
pronunciation: ""
type: antagonist
@@ -75,6 +75,7 @@ The Story Bible is a comprehensive reference system that helps you maintain cons
---
id: unique_id
name: Character Name
summary: "One line the app can show in a hover card"
slug: character-name
type: protagonist|antagonist|ally|enemy|mentor
status: active|draft|deceased|retired
@@ -142,6 +143,17 @@ Use `[[Link Text|file-name]]` or `[[file-name]]` to create cross-references:
- **Related Event:** [[The War of Ashes|history/war_of_ashes]]
```
### Automatic Linking in the Draft
The app does not require `[[wiki-links]]` to connect a draft to the Story
Bible — a name written in ordinary prose ("**Bixby** stood at the door") is
matched automatically against every entry's `name:` and `aliases:`, as long
as it's spelled and capitalized the same way in both places. Hovering a
matched name shows a card with the entry's `summary:` field and a link that
opens the full sheet; `[[wiki-links]]` and `@mentions` still work too, and
take priority if they ever land on the same word. A character sheet with no
`summary:` still gets matched and links open fine — the card just says so
and lets you add one.
### Mermaid Diagrams
Create visual relationship maps and timelines:
@@ -0,0 +1,11 @@
---
title: "Unsorted Scene"
slug: ""
status: draft
pov: ""
characters: []
setting: ""
word_count_target: 1800-2200
---
[Write your scene content here...]
@@ -1,21 +1,23 @@
---
# 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: ""
summary: []
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count_target: 1800-2200
---
[Write your scene content here...]
@@ -1,314 +0,0 @@
# Scene Management Scripts
Automate the creation and linking of planning and draft files.
---
## Scripts Available
### `create_scene.py`
Creates linked planning and draft files for new scenes.
---
## Quick Start
### 1. Create a New Scene Pair
```bash
# Navigate to your project root
cd /path/to/your/project
# Create a new scene (act 1, chapter 1, scene 2)
python scripts/create_scene.py --act 1 --chapter 1 --scene 2 --title "Chapter Two: The Journey"
```
This creates:
- `05-Plot/act_1/scene_02.md` (planning template)
- `06-First Draft/Act 1/scene02.md` (draft file)
- Both files are automatically linked to each other
### 2. Create Draft from Existing Planning
```bash
# If you have an existing planning file but no draft
python scripts/create_scene.py --from-planning 05-Plot/act_1/scene_01.md
```
This creates the corresponding draft file and links it.
### 3. Create Planning from Existing Draft
```bash
# If you have an existing draft file but no planning
python scripts/create_scene.py --from-draft "06-First Draft/Act 1/scene01.md"
```
This creates the corresponding planning file and links it.
---
## Usage Examples
### Minimal (auto-generates title)
```bash
python scripts/create_scene.py --scene 3
```
Creates scene 3 in act 1, chapter 1 with title "Chapter 1: Scene 3"
### Full specification
```bash
python scripts/create_scene.py \
--act 2 \
--chapter 3 \
--scene 5 \
--title "Chapter Three: The Revelation" \
--type chapter
```
### Batch create multiple scenes
```bash
for scene in {2..10}; do
python scripts/create_scene.py --chapter 1 --scene $scene
Done
```
---
## File Structure
### Planning Files (`05-Plot/`)
```
05-Plot/
├── _template.md # Template for new planning files
├── act_1/
│ ├── scene_01.md # Planning for act 1, scene 1
│ ├── scene_02.md # Planning for act 1, scene 2
│ └── ...
├── act_2/
│ ├── scene_01.md
│ └── ...
└── act_3/
└── ...
```
Each planning file contains:
- YAML frontmatter with metadata (act, chapter, scene, word count targets, etc.)
- `draft_file` field pointing to the corresponding draft file
- Scene planning sections (setup, reveal, notes)
### Draft Files (`06-First Draft/`)
```
06-First Draft/
├── _template.md # Template for new draft files
├── Act 1/
│ ├── scene01.md # Draft for act 1, scene 1
│ ├── scene02.md # Draft for act 1, scene 2
│ └── ...
├── Act 2/
│ ├── scene01.md
│ └── ...
└── Act 3/
└── ...
```
Each draft file contains:
- YAML frontmatter with metadata
- `planning_template` field pointing to the corresponding planning file
- Word count tracking (current, session, target from planning)
- Actual scene content
---
## Linking System
The two files are linked via relative paths in their frontmatter:
**Planning file (`05-Plot/act_1/scene_01.md`):**
```yaml
---
draft_file: "../../06-First Draft/Act 1/scene01.md"
---
```
**Draft file (`06-First Draft/Act 1/scene01.md`):**
```yaml
---
planning_template: "../../05-Plot/act_1/scene_01.md"
---
```
This bidirectional linking allows:
- Navigating from planning to draft and vice versa
- Sharing metadata between files
- Tracking progress across both
---
## Metadata Reference
### Planning File Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier (e.g., `scene_01_01_01`) |
| `title` | string | Scene/chapter title |
| `slug` | string | URL-friendly identifier |
| `act` | int | Act number |
| `chapter` | int | Chapter number |
| `scene` | int | Scene number |
| `type` | string | `scene` \| `chapter` \| `interlude` |
| `status` | string | `planning` \| `ready` \| `blocked` |
| `word_count_target` | int | Target word count |
| `word_count_min` | int | Minimum acceptable words |
| `word_count_max` | int | Maximum acceptable words |
| `pov` | string | Point of view character |
| `characters` | list | Characters in this scene |
| `setting` | string | Primary location |
| `date` | string | Date in story timeline |
| `time` | string | Time of day |
| `conflict` | string | Central conflict |
| `theme` | string | Thematic focus |
| `tone` | string | Emotional tone |
| `draft_file` | string | Path to linked draft file |
| `order` | int | Sort order |
### Draft File Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier (e.g., `draft_scene_01_01_01`) |
| `title` | string | Scene/chapter title |
| `slug` | string | URL-friendly identifier |
| `act` | int | Act number |
| `chapter` | int | Chapter number |
| `scene` | int | Scene number |
| `type` | string | `draft` |
| `status` | string | `not_started` \| `in_progress` \| `completed` \| `needs_revision` |
| `word_count` | int | Current word count |
| `word_count_updated` | string | Last word count update date |
| `session_word_count` | int | Words written in current session |
| `last_session` | string | Date of last writing session |
| `total_sessions` | int | Number of writing sessions |
| `pov` | string | Point of view character |
| `version` | int | Draft version |
| `planning_template` | string | Path to linked planning file |
---
## Integration with Your Workflow
### With Drag-and-Drop Reordering
If your editor supports drag-and-drop chapter reordering, the `order` field in the frontmatter is available for sorting. Update this field when reordering scenes.
### With File Watchers
To automatically create corresponding files when a new file is created:
1. **Option A: Use the script manually** (recommended)
```bash
# After creating a planning file manually, run:
python scripts/create_scene.py --from-planning 05-Plot/act_1/scene_05.md
```
2. **Option B: Set up a file watcher**
Create a watcher script that triggers `create_scene.py` when new files are detected.
3. **Option C: Editor integration**
If your editor supports custom commands, add a command that calls:
```bash
python scripts/create_scene.py --from-draft "${file}"
```
---
## Tips
1. **Naming Convention**
- Planning: `scene_{number:02d}.md` (e.g., `scene_01.md`, `scene_02.md`)
- Draft: `scene{number:02d}.md` (e.g., `scene01.md`, `scene02.md`)
- Note the slight difference (underscore vs. no underscore)
2. **Folder Structure**
- Planning: `05-Plot/act_{N}/`
- Draft: `06-First Draft/Act N/`
- Note the difference in folder naming (act_1 vs. Act 1)
3. **ID Generation**
- Planning IDs: `scene_{act:02d}_{chapter:02d}_{scene:02d}`
- Draft IDs: `draft_scene_{act:02d}_{chapter:02d}_{scene:02d}`
4. **Word Count Targets**
- Set in planning file
- Displayed in draft file
- Not automatically synced (manual update or via script)
---
## Troubleshooting
### "Module not found: yaml"
```bash
pip install pyyaml
```
### File paths not working
- Ensure you're running the script from the project root
- Check that the `scripts/` directory is in your project
- Verify relative paths are correct for your folder structure
### Frontmatter not parsing
- Ensure your files start with `---` on the first line
- Ensure there's a closing `---` after the frontmatter
- YAML is sensitive to indentation (use spaces, not tabs)
---
## Advanced Usage
### Custom Metadata
Add any additional fields to the frontmatter:
```bash
python scripts/create_scene.py --scene 1 --custom_field "value"
```
### Bulk Operations
Create multiple scenes at once:
```bash
# Create scenes 1-10 for chapter 1
for i in {1..10}; do
python scripts/create_scene.py --chapter 1 --scene $i
done
```
### Sync Existing Files
If you have existing files without links, you can manually add the frontmatter:
**Planning file:**
```yaml
---
draft_file: "../../06-First Draft/Act 1/scene01.md"
---
```
**Draft file:**
```yaml
---
planning_template: "../../05-Plot/act_1/scene_01.md"
---
```
---
## Related Files
- `05-Plot/_template.md` - Planning file template
- `06-First Draft/_template.md` - Draft file template
- `03-Story-Bible/` - Story Bible for character/location/lore tracking
@@ -1,395 +0,0 @@
#!/usr/bin/env python3
"""
Scene Creation Script
Creates linked planning and draft files for a new scene.
Usage:
python create_scene.py --act 1 --chapter 1 --scene 2 --title "Chapter Two: The Journey"
Or with minimal args (auto-generates title):
python create_scene.py --act 1 --chapter 1 --scene 2
Or create from existing planning file:
python create_scene.py --from-planning 05-Plot/act_1/scene_01.md
Or create from existing draft file:
python create_scene.py --from-draft "06-First Draft/Act 1/scene01.md"
"""
import argparse
import os
import re
import sys
from pathlib import Path
from datetime import datetime
# Get the project root (parent of scripts directory)
SCRIPTS_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPTS_DIR.parent
# Template paths
PLANNING_TEMPLATE = PROJECT_ROOT / "05-Plot" / "_template.md"
DRAFT_TEMPLATE = PROJECT_ROOT / "06-First Draft" / "_template.md"
def slugify(text):
"""Convert text to URL-friendly slug."""
if not text:
return ""
# Convert to lowercase
text = text.lower()
# Remove special characters
text = re.sub(r'[^\w\s-]', '', text)
# Replace spaces with hyphens
text = re.sub(r'[\s_]+', '-', text)
# Remove leading/trailing hyphens
text = text.strip('-')
return text
def generate_title(act, chapter, scene):
"""Generate a default title based on act/chapter/scene."""
return f"Chapter {chapter}: Scene {scene}"
def parse_yaml_frontmatter(content):
"""Parse YAML frontmatter from markdown file."""
import yaml
if not content.startswith('---'):
return {}
# Find the frontmatter
lines = content.split('\n')
if lines[0].strip() == '---':
frontmatter_lines = []
for line in lines[1:]:
if line.strip() == '---':
break
frontmatter_lines.append(line)
try:
return yaml.safe_load('\n'.join(frontmatter_lines)) or {}
except yaml.YAMLError:
return {}
return {}
def update_frontmatter(content, updates):
"""Update YAML frontmatter in markdown content."""
lines = content.split('\n')
if not lines[0].strip() == '---':
# No frontmatter, add it
yaml_content = "\n".join(f"{k}: {v}" for k, v in updates.items())
return f"---\n{yaml_content}\n---\n\n" + content
# Find and replace frontmatter
in_frontmatter = False
frontmatter_start = 0
frontmatter_end = 0
for i, line in enumerate(lines):
if line.strip() == '---':
if not in_frontmatter:
frontmatter_start = i
in_frontmatter = True
else:
frontmatter_end = i
break
if frontmatter_end == 0:
return content
# Parse existing frontmatter
existing = parse_yaml_frontmatter(content)
existing.update(updates)
# Generate new frontmatter
yaml_lines = ["---"]
for key, value in existing.items():
if isinstance(value, str):
yaml_lines.append(f"{key}: \"{value}\"")
elif isinstance(value, list):
yaml_lines.append(f"{key}: {value}")
else:
yaml_lines.append(f"{key}: {value}")
yaml_lines.append("---")
# Reconstruct content
new_content = "\n".join(yaml_lines) + "\n\n" + "\n".join(lines[frontmatter_end + 1:])
return new_content
def create_planning_file(act, chapter, scene, title, slug=None, **kwargs):
"""Create a new planning file."""
if slug is None:
slug = slugify(title)
# Create path
act_dir = PROJECT_ROOT / "05-Plot" / f"act_{act}"
act_dir.mkdir(parents=True, exist_ok=True)
filename = act_dir / f"scene_{scene:02d}.md"
# Read template
with open(PLANNING_TEMPLATE, 'r') as f:
template_content = f.read()
# Generate ID
scene_id = f"scene_{act:02d}_{chapter:02d}_{scene:02d}"
# Prepare frontmatter updates
frontmatter = {
'id': scene_id,
'title': title,
'slug': slug,
'act': act,
'chapter': chapter,
'scene': scene,
'order': scene,
'created': datetime.now().strftime("%Y-%m-%d"),
'updated': datetime.now().strftime("%Y-%m-%d"),
}
# Update with any additional kwargs
frontmatter.update(kwargs)
# Update draft_file path if not provided
if 'draft_file' not in frontmatter:
draft_path = f"../../06-First Draft/Act {act}/scene{scene:02d}.md"
frontmatter['draft_file'] = draft_path
# Update content
content = update_frontmatter(template_content, frontmatter)
# Write file
with open(filename, 'w') as f:
f.write(content)
return filename
def create_draft_file(act, chapter, scene, title, slug=None, planning_path=None, **kwargs):
"""Create a new draft file."""
if slug is None:
slug = slugify(title)
# Create path
act_dir = PROJECT_ROOT / "06-First Draft" / f"Act {act}"
act_dir.mkdir(parents=True, exist_ok=True)
filename = act_dir / f"scene{scene:02d}.md"
# Read template
with open(DRAFT_TEMPLATE, 'r') as f:
template_content = f.read()
# Generate ID
scene_id = f"draft_scene_{act:02d}_{chapter:02d}_{scene:02d}"
# Prepare frontmatter updates
frontmatter = {
'id': scene_id,
'title': title,
'slug': slug,
'act': act,
'chapter': chapter,
'scene': scene,
'created': datetime.now().strftime("%Y-%m-%d"),
'updated': datetime.now().strftime("%Y-%m-%d"),
}
# Update with any additional kwargs
frontmatter.update(kwargs)
# Set planning_template path if not provided
if 'planning_template' not in frontmatter:
if planning_path:
# Use the provided planning path
relative_path = os.path.relpath(planning_path, act_dir)
frontmatter['planning_template'] = relative_path
else:
# Generate default path
planning_path = f"../../05-Plot/act_{act}/scene_{scene:02d}.md"
frontmatter['planning_template'] = planning_path
# Update content
content = update_frontmatter(template_content, frontmatter)
# Write file
with open(filename, 'w') as f:
f.write(content)
return filename
def link_files(planning_path, draft_path):
"""Ensure both files are linked to each other."""
# Update planning file with draft path
with open(planning_path, 'r') as f:
planning_content = f.read()
relative_draft = os.path.relpath(draft_path, Path(planning_path).parent)
planning_content = update_frontmatter(planning_content, {
'draft_file': relative_draft
})
with open(planning_path, 'w') as f:
f.write(planning_content)
# Update draft file with planning path
with open(draft_path, 'r') as f:
draft_content = f.read()
relative_planning = os.path.relpath(planning_path, Path(draft_path).parent)
draft_content = update_frontmatter(draft_content, {
'planning_template': relative_planning
})
with open(draft_path, 'w') as f:
f.write(draft_content)
def create_scene_pair(act, chapter, scene, title=None, **kwargs):
"""Create both planning and draft files."""
if title is None:
title = generate_title(act, chapter, scene)
slug = slugify(title)
# Create planning file
planning_path = create_planning_file(
act, chapter, scene, title, slug, **kwargs
)
# Create draft file (without planning_path initially)
draft_path = create_draft_file(
act, chapter, scene, title, slug, **kwargs
)
# Link them together
link_files(planning_path, draft_path)
print(f"✓ Created planning file: {planning_path.relative_to(PROJECT_ROOT)}")
print(f"✓ Created draft file: {draft_path.relative_to(PROJECT_ROOT)}")
print(f"✓ Linked files together")
return planning_path, draft_path
def create_from_planning(planning_path_str):
"""Create a draft file from an existing planning file."""
planning_path = Path(planning_path_str)
if not planning_path.exists():
print(f"Error: Planning file not found: {planning_path}")
sys.exit(1)
# Parse planning file
with open(planning_path, 'r') as f:
planning_content = f.read()
frontmatter = parse_yaml_frontmatter(planning_content)
if not frontmatter:
print(f"Error: No valid frontmatter in {planning_path}")
sys.exit(1)
act = frontmatter.get('act', 1)
chapter = frontmatter.get('chapter', 1)
scene = frontmatter.get('scene', 1)
title = frontmatter.get('title', generate_title(act, chapter, scene))
# Create draft file
draft_path = create_draft_file(
act, chapter, scene, title,
planning_path=planning_path
)
# Link files
link_files(planning_path, draft_path)
print(f"✓ Created draft file: {draft_path.relative_to(PROJECT_ROOT)}")
print(f"✓ Linked to planning file: {planning_path.relative_to(PROJECT_ROOT)}")
return draft_path
def create_from_draft(draft_path_str):
"""Create a planning file from an existing draft file."""
draft_path = Path(draft_path_str)
if not draft_path.exists():
print(f"Error: Draft file not found: {draft_path}")
sys.exit(1)
# Parse draft file
with open(draft_path, 'r') as f:
draft_content = f.read()
frontmatter = parse_yaml_frontmatter(draft_content)
if not frontmatter:
print(f"Error: No valid frontmatter in {draft_path}")
sys.exit(1)
act = frontmatter.get('act', 1)
chapter = frontmatter.get('chapter', 1)
scene = frontmatter.get('scene', 1)
title = frontmatter.get('title', generate_title(act, chapter, scene))
# Create planning file
planning_path = create_planning_file(
act, chapter, scene, title,
draft_file=draft_path
)
# Link files
link_files(planning_path, draft_path)
print(f"✓ Created planning file: {planning_path.relative_to(PROJECT_ROOT)}")
print(f"✓ Linked to draft file: {draft_path.relative_to(PROJECT_ROOT)}")
return planning_path
def main():
parser = argparse.ArgumentParser(
description='Create linked planning and draft files for scenes.'
)
# For creating new scene pair
parser.add_argument('--act', type=int, default=1, help='Act number')
parser.add_argument('--chapter', type=int, default=1, help='Chapter number')
parser.add_argument('--scene', type=int, required=True, help='Scene number')
parser.add_argument('--title', type=str, default=None, help='Scene title')
parser.add_argument('--type', type=str, default='scene',
choices=['scene', 'chapter', 'interlude'],
help='Scene type')
# For creating from existing files
parser.add_argument('--from-planning', type=str, default=None,
help='Create draft from existing planning file')
parser.add_argument('--from-draft', type=str, default=None,
help='Create planning from existing draft file')
args = parser.parse_args()
if args.from_planning:
create_from_planning(args.from_planning)
elif args.from_draft:
create_from_draft(args.from_draft)
else:
create_scene_pair(
act=args.act,
chapter=args.chapter,
scene=args.scene,
title=args.title,
type=args.type
)
if __name__ == '__main__':
main()