36 Commits

Author SHA1 Message Date
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
landon 2471312f05 feat: Remove fallback to Cookiecutters, use bundled template only
- Remove fallback to ~/Documents/Cookiecutters/snowflake from default_project_template()
- Panic with clear error if bundled template not found
- Update tests to use 05-First Draft instead of 06-First Draft
- Update comments to reflect merged structure (05-First Draft contains planning+draft)

The code now only uses the bundled template at templates/snowflake/ relative to the
executable, ensuring all new projects use the merged 05-First Draft structure.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 18:27:54 -05:00
landon aab943de6b manual renumbering of template files 2026-09-16 18:14:17 -05:00
landon 114f4ff697 docs: Update all references from old 05-Plot/06-First Draft to merged 05-First Draft
- Update project_template test to verify old directories are NOT created
- Update hover text and comments in project.rs
- Update comments and code in file_list.rs
- Update comment in spelling.rs
- Add test assertions to verify 05-Plot and 06-First Draft don't exist in new projects

This ensures all code references match the merged directory structure where
planning and draft content are combined in 05-First Draft with YAML frontmatter.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 15:34:36 -05:00
landon 0583bc077c feat: Add aliases support to Story Bible for flexible linking
- Add aliases field to StoryBibleItem struct
- Add items_by_alias HashMap to StoryBibleIndex for quick alias lookup
- Add find_by_alias() method and update find_by_partial_name() to search aliases
- Parse aliases from YAML frontmatter (list and comma-separated formats)
- Update find_references() to match aliases in addition to IDs and names
- Fix @mention pattern matching to properly stop at spaces
- Add comprehensive tests for aliases functionality
- Update README.md with aliases documentation and usage examples

This allows Story Bible entries to have alternative names that can be
referenced in draft text using wiki-style [[...]] or mention-style @ syntax.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 14:18:57 -05:00
landon a0d1c3c206 feat: Remove ### Rough Draft: marker, use YAML frontmatter only for draft files
- Everything after YAML frontmatter closing --- is now considered the draft content
- No ### Rough Draft: marker needed
- Chapter titles are handled by the program on export based on file order
- Draft content should not include chapter titles
- Updated default_new_file_template to use YAML frontmatter
- Updated preprocess.rs to parse YAML frontmatter for metadata
- Updated all relevant tests
- Updated documentation in help.rs
- Updated template files

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 13:33:23 -05:00
landon aafe1786b5 feat: Add auto-snapshot feature with git commit every N words
- Add new module src/app/auto_snapshot.rs for auto-snapshot functionality
- Add configuration options: auto_snapshot_enabled (default: false) and
  auto_snapshot_threshold (default: 250 words)
- Automatically create git commits when word count crosses threshold boundaries
- Add 'Edit -> Snapshots' menu option to open snapshots window
- Snapshots window shows: enable/disable toggle, threshold setting, current
  word count, next snapshot target, list of all snapshots with rollback buttons
- Manual snapshot creation via button in snapshots window
- Rollback to any previous snapshot with one click
- Snapshot info includes: word count, timestamp, commit message, commit hash
- Make run_git in gitsync.rs public for use by auto_snapshot module
- Update README.md with Auto-Snapshots documentation

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 12:11:35 -05:00
landon dcb97da518 feat: Add Story Bible breakout editor window
- Add new module src/app/story_bible_editor.rs for dedicated Story Bible editing
- Add 'Edit -> Story Bible' menu option to open the editor
- Tree view (left 1/3) shows Story Bible directory structure with collapsible folders
- Editor (right 2/3) allows editing selected Story Bible items
- Auto-load first file on open, support Ctrl+S to save
- Rebuild Story Bible index after saving to pick up changes
- Update README.md with Story Bible Editor documentation

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 11:21:15 -05:00
landon 55bf006216 docs: Update README for merged 05-First Draft with YAML frontmatter
- Update all references from 06-First Draft to 05-First Draft
- Remove outdated reference to 05-Plot
- Add new section explaining YAML frontmatter support for planning metadata

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 10:08:20 -05:00
landon 449ad4439f feat: Merge 05-Plot and 06-First Draft into 05-First Draft with YAML frontmatter
- Add YAML frontmatter support with split_yaml_frontmatter() in preprocess.rs
- Update create_scene_pair to create single merged files with YAML frontmatter
- Rename 06-First Draft to 05-First Draft and merge with 05-Plot content
- Update default_project_open_subdir to '05-First Draft'
- Add capitalizes filter to MiniJinja environment for template rendering
- Fix template placeholder escaping in CONSISTENCY.md
- Update all references from 06-First Draft and 05-Plot to 05-First Draft
- Update test expectations to match new folder structure

The merged approach uses YAML frontmatter (--- delimiters) at the top of
each draft file to store planning metadata, allowing it to be collapsed
in the editor while keeping all planning and draft content in one file.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 10:06:32 -05:00
landon 477c59cb79 docs: Add Story Bible documentation to README
- Document Story Bible feature in the Tools section
- Add Story Bible to the reference material cross-reference
- Describe reference syntaxes (wiki-style and mention-style)
- Document highlighting and navigation features

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 08:58:13 -05:00
landon ca3e476a14 feat: Implement bidirectional Story Bible links with draft text
- Add story_bible.rs module with StoryBibleIndex, StoryBibleItem, and
  StoryBibleReference types for indexing Story Bible content
- Support wiki-style [[...]] and mention-style @... syntax for references
- Highlight Story Bible references in editor with purple background
- Add Story Bible window (View > Tools > Story Bible) to browse items
- Show references from current draft file in Story Bible window
- Allow clicking references to jump to their location in the editor
- Auto-index Story Bible items from common directories (03-Characters,
  02-Story Bible, Characters, etc.)
- Extract metadata from YAML frontmatter or filenames
- Add walkdir dependency for directory scanning
- Fix pre-existing format string errors in workspace.rs

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 08:56:28 -05:00
landon 511a7588fa docs: Update README with heading-based nesting outlines and changelog
- Updated Outline section to document heading hierarchy preservation
- Added Changelog section documenting latest features
- Documented linked scene/chapter templates
- Documented automatic workspace refresh

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 14:58:48 -05:00
landon cd8663349a feat: Implement heading-based nesting outlines in Outline window
- Add depth and path fields to Beat struct to track heading hierarchy
- Rewrite beats() parser to use heading stack and process_headings() helper
- Add BeatTree enum with Heading and Beat variants for tree structure
- Add build_beat_tree() to convert flat beats to nested tree
- Add render_beat_tree() for recursive tree rendering with collapsible headers
- Add count_beat_tree() helper to count beats in subtree
- Replace flat list rendering with tree-based rendering in outline_window()

The implementation currently groups beats by section, with infrastructure
in place for full hierarchical nesting based on beat.path.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 14:56:35 -05:00
landon 921f3e74c5 feat: Add 'New Scene' button to file list menu for creating linked planning+draft files
- Added create_scene_pair() method to workspace.rs
- Added '+ New Scene' button in file_list.rs UI
- Button creates both planning file (05-Plot/act_N/scene_XX.md) and draft file (06-First Draft/Act N/sceneXX.md)
- Files are automatically linked via YAML frontmatter (draft_file and planning_template fields)
- Auto-detects current act from folder context
- Auto-generates next scene number
- Selects the draft file after creation (ready to edit)
- Includes hover text explaining the feature

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 14:27:30 -05:00
landon b183237d59 feat: Add linked scene/chapter templates with planning-draft file references
- Updated Plot scene template with YAML frontmatter for metadata tracking
- Updated First Draft scene template with YAML frontmatter
- Added _template.md files in both Plot and First Draft folders
- Added create_scene.py script for automatic file creation and linking
- Planning files reference draft files via 'draft_file' field
- Draft files reference planning files via 'planning_template' field
- Word count targets tracked in planning, current count in draft
- Added comprehensive scripts/README.md with usage guide
- Supports --from-planning and --from-draft for creating missing counterparts

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 14:20:44 -05:00
landon d311e87836 feat: Transform 03-Characters into comprehensive Story Bible
- Renamed 03-Characters to 03-Story-Bible with expanded structure
- Added enhanced character templates with YAML frontmatter for metadata
- Created new sections: Locations, Lore, Objects, Relationships, Timeline, Themes
- Added templates for: regions, cities, buildings, historical events, factions
- Added templates for: artifacts, weapons, character relationships
- Added world rules document for magic/technology/society tracking
- Added CONSISTENCY.md for continuity tracking
- Added comprehensive README with usage guide
- Added index files for each section with cross-linking
- Implemented wiki-link syntax [[file]] for cross-references
- Added Mermaid diagram support for relationship maps and timelines
- Tagging system for filtering and organization

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 13:58:56 -05:00
84 changed files with 9888 additions and 1012 deletions
Generated
+21
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",
@@ -1861,6 +1881,7 @@ dependencies = [
"serde_json",
"spellbook",
"ureq",
"walkdir",
"zip",
]
+5
View File
@@ -12,6 +12,7 @@ serde_json = "1"
pulldown-cmark = { version = "0.12", default-features = false }
zip = { version = "2", default-features = false, features = ["deflate"] }
dirs = "5"
walkdir = "2"
# Native open/save dialogs via the XDG Desktop Portal (no GTK build dependency).
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
# Blocking HTTP client for talking to a LanguageTool server. The rustls TLS
@@ -23,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
+255 -49
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`:
@@ -182,6 +188,71 @@ A single number (`## Word Count Target: 1800`) sets a point goal; ranges accept
`-`, `–`, `to`, and grouped digits (`1,500`). Like the other header lines, the
target is stripped from the exported document.
### YAML frontmatter for planning metadata
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
---
title: "Chapter One: The Beginning"
slug: chapter-one-the-beginning
act: 1
chapter: 1
scene: 1
status: draft
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
```
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
Open a **project root** — a folder holding the manuscript alongside the
@@ -189,7 +260,7 @@ characters, outline, premise and the rest — and the app recognises the layout
instead of treating every file in it as a chapter.
The trigger is simple: if the folder you open contains a subfolder named like
the **manuscript folder** (`06-First Draft` by default), it is a project. A
the **manuscript folder** (`05-First Draft` by default), it is a project. A
plain folder of chapters has no such subfolder, so it behaves exactly as before
— everything in it is the manuscript.
@@ -202,7 +273,7 @@ In a project:
* **Everything else is reference** — character sheets, the outline, the scratch
pad — and is **not listed in the file panel**, which stays about the book.
Reference material is reached through the windows that understand it
([Characters](#characters-tools--characters), [Outline](#outline-tools--outline)),
([Characters](#characters-tools--characters), [Story Bible](#story-bible-tools--story-bible), [Outline](#outline-tools--outline)),
and it stays out of the order and out of the export.
Turn on **View ▸ Show reference files** to list it in the tree anyway, dimmed,
@@ -212,9 +283,8 @@ In a project:
* **File ▸ New project…** opens the project root, not the drafting folder.
Set which folder is the manuscript under **Settings ▸ New project… ▸ Manuscript
folder**. The number is optional — `First Draft` matches `06-First Draft` — and
folder**. The number is optional — `First Draft` matches `05-First Draft` — and
the setting is global, so every project on this machine uses the same layout.
If your writing actually lives in `05-Plot`, point it there.
### Hidden folders
@@ -300,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
@@ -313,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…)
@@ -330,6 +404,68 @@ it shows:
changes; this one cannot. Long values are trimmed, since a grid is a
quick-look summary.
### Story Bible (Tools ▸ Story Bible…)
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 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 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, 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 `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
name: "John Smith"
type: character
aliases: ["Johnny", "JS"]
summary: "A retired locksmith dragged back for one last job."
---
```
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…)
For a dedicated editing experience, use the **Story Bible breakout editor**. This
provides a focused workspace for managing your Story Bible items:
* **Tree view** (left 1/3 of window): Shows the complete Story Bible directory structure
with collapsible folders for Characters, Locations, Lore, and other categories
* **Editor** (right 2/3 of window): Full-featured text editor for the selected item
* **Auto-load**: Select any file from the tree to load it into the editor
* **Save**: Click **Save** or press `Ctrl+S` to write changes back to disk
* **Index updates**: Saving a file automatically rebuilds the Story Bible index
The editor is ideal for extended Story Bible work, allowing you to browse and edit
items without leaving the context of your reference material.
### Outline (Tools ▸ Outline…)
The snowflake outline files are scaffolds of HTML comments, one per beat
@@ -341,12 +477,41 @@ This window reads the scaffold back: every beat it names, and whether prose has
been written under it, with a progress bar over the whole outline. A beat counts
as written when there is ordinary text between its prompt and the next one.
**Heading hierarchy is preserved**: markdown headings (`#`, `##`, `###`) in the
outline files create a nested tree structure in the Outline window. Beats under
the same heading are grouped together under a collapsible header showing the
progress for that section (`3/5` beats written, for example). This makes it easy
to navigate large outlines and see progress at every level of your story structure.
Beats generated by [the Mistral tool](#plot-beats-mistral) can be appended
straight into the matching act file with **📝 Append to act_0N.md**, which
appears when a run filled exactly one act. It appends under a dated comment
rather than replacing, because the prompts are the reason the file is worth
keeping.
### Auto-Snapshots (Edit ▸ Snapshots…)
Never lose your work again. Enable automatic git commits at regular word-count
intervals to create recovery points you can roll back to at any time.
* **Enable/Disable**: Turn auto-snapshotting on or off from the Snapshots window
* **Configurable threshold**: Set how many words between snapshots (default: 250)
* **Automatic commits**: Commits are created automatically when you cross a
threshold boundary (250, 500, 750, 1000, etc.)
* **Manual snapshots**: Click "Create Manual Snapshot" to force a commit at any time
* **Rollback**: Restore your entire workspace to any previous snapshot
* **Live status**: See current word count and words until next snapshot
Snapshots appear in the Snapshots window with:
- Word count at the time of the snapshot
- Timestamp
- Commit message
- Commit hash (shown on hover)
**Important**: This feature requires the workspace to be a git repository. Snapshots
are regular git commits with messages like "Auto-snapshot at N words", so they
integrate with your normal git workflow.
### Revision status
Give a file a `Status:` header line and the file panel shows a compact badge for
@@ -489,22 +654,25 @@ 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 **`06-First Draft`** folder (where the snowflake template keeps the
with the **`05-First Draft`** folder (where the snowflake template keeps the
chapter files in `Act 1/`, `Act 2/` and `Act 3/` subfolders) automatically
expanded in the file list. If the template didn't produce that folder, the
status bar notes that nothing is marked as the manuscript.
@@ -517,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 `06-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 |
@@ -545,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
@@ -566,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:
---
```
@@ -591,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
@@ -640,7 +812,7 @@ Word list…** is that place.
every name in them at once. It looks for a `Characters` folder (a numeric
prefix like `03-Characters` is fine) beside the workspace and up to three
levels above it, so it still finds them while you are editing in
`06-First Draft`. Both `# Bixby Reynolds` and `# Name: Jonathan Bronski`
`05-First Draft`. Both `# Bixby Reynolds` and `# Name: Jonathan Bronski`
headings are understood, and an unfilled template sheet (`# Name`,
`# Protagonist`) contributes nothing. Each part of a name is listed
separately, since prose says *Bixby* far more often than *Bixby Reynolds*.
@@ -782,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.
@@ -841,3 +1016,34 @@ rendered as a text placeholder (not embedded).
A quick reference for all of this — plus the chapter metadata, draft-marker
header, and comment conventions — is built in: open **Help ▸ Markdown cheatsheet**
from the menu bar.
## Changelog
### Latest
- **Heading-based nesting outlines**: Markdown headings (`#`, `##`, `###`) in outline
files now create a hierarchical tree structure in the Outline window. Beats under
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.
- **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.
+329
View File
@@ -0,0 +1,329 @@
//! Auto-snapshot feature: automatically commit every N words with rollback support.
//!
//! Provides word-count-based versioning for writers who want automatic save points.
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
/// Information about a snapshot for rollback.
#[derive(Debug, Clone)]
pub struct SnapshotInfo {
/// The git commit hash for this snapshot.
pub commit_hash: String,
/// The word count at the time of this snapshot.
pub word_count: usize,
/// Timestamp when the snapshot was created (UTC seconds since epoch).
pub timestamp: u64,
/// The commit message used.
pub message: String,
}
impl SnapshotInfo {
/// Create a new snapshot info.
pub fn new(commit_hash: String, word_count: usize, message: String) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self {
commit_hash,
word_count,
timestamp,
message,
}
}
/// Format the timestamp as a human-readable string.
pub fn formatted_time(&self) -> String {
let secs = self.timestamp;
let (y, m, d) = crate::app::util::civil_from_days((secs / 86_400) as i64);
let hours = (secs % 86_400) / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, hours, minutes, seconds)
}
}
impl App {
/// Check if we should create a snapshot based on word count.
/// Returns true if a snapshot was created.
pub(super) fn check_auto_snapshot(&mut self) -> bool {
// Only check if the feature is enabled and we have a selected file
if !self.config.auto_snapshot_enabled || self.selected.is_none() {
return false;
}
// Get current word count
let current_words = self.current_word_count();
// If we haven't set a baseline yet, set it to the current word count
// This handles the case when the feature is enabled mid-session
if self.last_snapshot_word_count == 0 {
self.last_snapshot_word_count = current_words;
return false;
}
// Check if we've crossed a threshold boundary
let threshold = self.config.auto_snapshot_threshold;
let threshold_crossed = current_words / threshold > self.last_snapshot_word_count / threshold;
// Also check if we've added at least threshold words since last snapshot
let words_since_last = current_words.saturating_sub(self.last_snapshot_word_count);
if threshold_crossed && words_since_last >= threshold {
// Create a snapshot
let snapshot_message = format!(
"Auto-snapshot at {} words",
current_words
);
// Save and commit
self.save_current();
self.persist_order();
self.persist_titles();
let outcome = Self::git_commit_only(self.workspace(), &snapshot_message);
if outcome.ok {
// Get the commit hash
if let Some(hash) = Self::get_head_commit_hash(self.workspace()) {
let snapshot = SnapshotInfo::new(hash, current_words, snapshot_message);
self.snapshots.push(snapshot);
self.last_snapshot_word_count = current_words;
self.status = format!("Auto-snapshot created at {} words", current_words);
return true;
}
}
// If commit failed, just update the word count to avoid retrying
self.last_snapshot_word_count = current_words;
}
false
}
/// Get the HEAD commit hash.
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()
.find(|l| !l.starts_with("$ git"))
.map(|s| s.trim().to_string())
} else {
None
}
}
/// Commit only (no pull/push) - used for snapshots.
pub(super) fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome {
let mut log = String::new();
let mut ok = true;
// Add all changes
let (_, o) = crate::gitsync::run_git(workspace, &["add", "-A"]);
log.push_str(&o);
// Check if there are changes
let (has_changes, status) = crate::gitsync::run_git(workspace, &["status", "--porcelain"]);
let dirty = has_changes && !status.lines().skip(1).all(|l| l.trim().is_empty());
if dirty {
let (c_ok, c_out) = crate::gitsync::run_git(workspace, &["commit", "-m", message]);
log.push_str(&c_out);
ok &= c_ok;
} else {
log.push_str("(nothing to commit)\n");
}
crate::gitsync::GitOutcome { ok, log }
}
/// Get the current word count of the open file.
pub(super) fn current_word_count(&self) -> usize {
if self.selected.is_none() {
return 0;
}
// Use the buffer directly - this counts all words including header
// For prose-only counting, we'd need to parse and use the body
crate::app::util::count_words(&self.buffer)
}
/// Create a snapshot commit with the given message.
pub(super) fn create_snapshot_manually(&mut self) {
if !self.config.auto_snapshot_enabled {
self.status = "Auto-snapshot feature is disabled".to_string();
return;
}
self.save_current();
self.persist_order();
self.persist_titles();
let current_words = self.current_word_count();
let snapshot_message = format!(
"Manual snapshot at {} words",
current_words
);
let outcome = Self::git_commit_only(self.workspace(), &snapshot_message);
if outcome.ok {
if let Some(hash) = Self::get_head_commit_hash(self.workspace()) {
let snapshot = SnapshotInfo::new(hash, current_words, snapshot_message);
self.snapshots.push(snapshot);
self.last_snapshot_word_count = current_words;
self.status = format!("Manual snapshot created at {} words", current_words);
}
} else {
self.status = format!("Failed to create snapshot: {}", outcome.log);
}
}
/// Roll back to a specific snapshot.
pub(super) fn rollback_to_snapshot(&mut self, index: usize) {
// Clone the snapshot info we need to avoid borrow issues
let snapshot_hash = self.snapshots.get(index).map(|s| s.commit_hash.clone());
let snapshot_word_count = self.snapshots.get(index).map(|s| s.word_count);
if let (Some(hash), Some(word_count)) = (snapshot_hash, snapshot_word_count) {
// Checkout the commit
let (ok, log) = crate::gitsync::run_git(
self.workspace(),
&["checkout", &hash],
);
if ok {
self.status = format!(
"Rolled back to snapshot at {} words",
word_count
);
// Refresh the workspace to reflect the checkout
self.open_workspace();
// Reset last snapshot word count to avoid immediate re-snapshot
self.last_snapshot_word_count = word_count;
} else {
self.status = format!("Failed to rollback: {}", log);
}
}
}
/// Show the snapshots window.
pub(super) fn snapshots_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_snapshots;
egui::Window::new("Snapshots")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(600.0)
.default_height(400.0)
.show(ctx, |ui| {
self.snapshots_window_contents(ui);
});
self.show_snapshots = open;
}
/// Contents of the snapshots window.
fn snapshots_window_contents(&mut self, ui: &mut egui::Ui) {
ui.heading("Auto-Snapshots");
ui.separator();
// Settings
ui.horizontal(|ui| {
ui.label("Enabled:");
if ui.checkbox(&mut self.config.auto_snapshot_enabled, "").changed() {
self.config.save();
if !self.config.auto_snapshot_enabled {
// Reset tracking when disabled
self.last_snapshot_word_count = 0;
}
}
ui.label("Threshold:");
let mut threshold = self.config.auto_snapshot_threshold;
if ui.add(egui::DragValue::new(&mut threshold).range(1..=10000)).changed() {
self.config.auto_snapshot_threshold = threshold;
self.config.save();
}
ui.label("words");
});
ui.separator();
// Current status
let current_words = self.current_word_count();
ui.label(format!("Current word count: {}", current_words));
if self.config.auto_snapshot_enabled {
let next_snapshot = ((current_words / self.config.auto_snapshot_threshold) + 1)
* self.config.auto_snapshot_threshold;
let words_to_next = next_snapshot.saturating_sub(current_words);
ui.label(format!(
"Next snapshot at {} words ({} words to go)",
next_snapshot, words_to_next
));
}
ui.separator();
// Manual snapshot button
if ui.button("Create Manual Snapshot").clicked() {
self.create_snapshot_manually();
}
ui.separator();
// Snapshot list
ui.heading("Snapshots");
if self.snapshots.is_empty() {
ui.label("No snapshots yet. Start writing to create auto-snapshots!");
} else {
egui::ScrollArea::vertical()
.auto_shrink(false)
.show(ui, |ui| {
// Sort by timestamp descending (newest first)
let mut sorted_snapshots = self.snapshots.clone();
sorted_snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
for (i, snapshot) in sorted_snapshots.iter().enumerate() {
// Find the original index in self.snapshots
let orig_index = self.snapshots.iter()
.position(|s| s.timestamp == snapshot.timestamp)
.unwrap_or(i);
ui.horizontal(|ui| {
ui.label(format!("{} words", snapshot.word_count));
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);
}
// Show commit hash on hover
if ui.button("ℹ️").on_hover_text(&snapshot.commit_hash).clicked() {
// Could copy to clipboard in the future
}
});
}
});
}
ui.separator();
if ui.button("Close").clicked() {
self.show_snapshots = false;
}
}
}
+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;
}
+281 -5
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,10 +65,24 @@ 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])
.show(ui, |ui| {
// Prepare Story Bible reference ranges for highlighting
let bible_ranges: Vec<(usize, usize)> = self
.current_references
.iter()
.map(|r| (r.start, r.end))
.collect();
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
let job = build_editor_job(
text,
@@ -51,6 +91,8 @@ impl App {
&ranges,
&find_ranges,
find_active,
&bible_ranges,
dim_range,
wrap_width,
);
ui.fonts(|f| f.layout_job(job))
@@ -97,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;
@@ -105,6 +148,8 @@ impl App {
self.find_needs_refresh = true;
self.spell_dirty = true;
self.spell_last_edit = Some(Instant::now());
// Update Story Bible references for the current file
self.update_current_references();
}
// Markdown formatting hotkeys, applied to the current selection
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
@@ -130,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),
@@ -160,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)
@@ -173,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| {
@@ -325,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.
@@ -348,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;
}
}
}
@@ -358,6 +534,7 @@ impl App {
/// get a red underline, grammar/style issues a blue one. `find` are
/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent
/// highlight; the one at index `find_active` (if any) is shaded more strongly.
/// `bible` are Story Bible reference ranges, highlighted with a distinctive color.
pub(super) fn build_editor_job(
text: &str,
size: f32,
@@ -365,6 +542,8 @@ pub(super) fn build_editor_job(
grammar: &[(usize, usize, bool)],
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};
@@ -372,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 and find), 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);
@@ -388,6 +568,14 @@ pub(super) fn build_editor_job(
points.push(s);
points.push(e);
}
for &(s, e) in bible {
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();
@@ -397,6 +585,8 @@ pub(super) fn build_editor_job(
// Translucent so the highlight reads in both light and dark themes.
let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70);
let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150);
// Story Bible reference highlight - purple/blue color
let bible_bg = egui::Color32::from_rgba_unmultiplied(138, 43, 226, 70);
let width = (size * 0.08).max(1.5);
for w in points.windows(2) {
@@ -421,6 +611,18 @@ pub(super) fn build_editor_job(
find_bg
};
}
// Check for Story Bible references and apply background highlight
// Only apply if not already highlighted by find
let is_find_highlight = find.iter().any(|&(s, e)| s < e && a >= s && b <= e);
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
@@ -497,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).
///
@@ -680,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.
@@ -810,8 +1070,8 @@ mod tests {
#[test]
fn commented_prose_is_dropped_from_the_exported_body() {
// The whole point of the hotkey: preprocess must not see the hidden line.
let md = "### Rough Draft:\n\nKeep this.\n\n<!-- Cut this. -->\n";
let body = crate::preprocess::parse(md, "### Rough Draft:").body;
let md = "---\ntitle: Test\n---\n\nKeep this.\n\n<!-- Cut this. -->\n";
let body = crate::preprocess::parse(md, "").body;
assert!(body.contains("Keep this."));
assert!(!body.contains("Cut this."));
}
@@ -825,3 +1085,19 @@ mod tests {
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
}
}
impl App {
/// Update the current_references list with Story Bible references found in the buffer.
pub(super) fn update_current_references(&mut self) {
if let Some(idx) = self.selected {
let file_path = self.files[idx].clone();
let mut refs = crate::story_bible::find_references(&self.buffer, &self.story_bible_index);
for r in refs.iter_mut() {
r.file_path = file_path.clone();
}
self.current_references = refs;
} else {
self.current_references.clear();
}
}
}
+331 -151
View File
@@ -10,14 +10,14 @@ const MAX_DISPLAY_NAME_LEN: usize = 30;
/// Convert a manuscript folder path to its corresponding outline file path.
///
/// For the snowflake template:
/// - `06-First Draft/Act 1` → `04-Outline/scene_breakdown/act_01.md`
/// - `06-First Draft/Act 2` → `04-Outline/scene_breakdown/act_02.md`
/// - `05-First Draft/Act 1` → `04-Outline/scene_breakdown/act_01.md`
/// - `05-First Draft/Act 2` → `04-Outline/scene_breakdown/act_02.md`
/// - etc.
fn outline_path_for_folder(folder_path: &str) -> Option<String> {
// Split the path into components
let parts: Vec<&str> = folder_path.split('/').collect();
// We expect paths like "06-First Draft/Act 1"
// We expect paths like "05-First Draft/Act 1"
if parts.len() < 2 {
return None;
}
@@ -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],
egui::Button::new(text)
.frame(false)
.truncate(),
.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(
egui::RichText::new(count.to_string())
.small()
.weak(),
);
if is_manuscript_root {
ui.label(
egui::RichText::new("· manuscript")
// Fixed widths, so what the row
// actually consumes is what `name_w`
// was budgeted against.
ui.add_sized(
[COUNT_W, 20.0],
egui::Label::new(
egui::RichText::new(count.to_string())
.small()
.weak(),
)
.truncate(),
);
if show_tag {
ui.add_sized(
[MANUSCRIPT_W, 20.0],
egui::Label::new(
egui::RichText::new("· manuscript")
.small()
.weak(),
)
.truncate(),
)
.on_hover_text(
"These files are the book: ordered, \
numbered and exported. Everything \
@@ -384,33 +538,75 @@ impl App {
egui::Id::new(("dnd", &name)),
idx,
|ui| {
ui.label(
egui::RichText::new("⠿")
.monospace()
.weak(),
// Sized, not free-standing: the
// budget below counts on the
// handle being exactly this wide.
ui.add_sized(
[HANDLE_W, 20.0],
egui::Label::new(
egui::RichText::new("⠿")
.monospace()
.weak(),
),
);
},
);
// Reserve room on the right for a per-file
// word-count-target bar when the file sets one.
let bar_w = 44.0;
let reserve = if meta.goal.is_some() {
bar_w + 6.0
} else {
0.0
};
let label_w = (row_width
- indent
- HANDLE_W
- reserve)
.max(24.0);
// Everything the row draws has to come
// out of `row_width`: the handle, the
// gap after each item, and whatever
// trailing badges the file has earned.
// A badge is dropped rather than let the
// name shrink below `MIN_LABEL_W` — a row
// that overflows would widen the panel.
let mut trailing = 0.0;
let head = row_width - indent - HANDLE_W - gap;
let show_bar = meta.goal.is_some()
&& head - (gap + BAR_W) >= MIN_LABEL_W;
if show_bar {
trailing += gap + BAR_W;
}
let show_status = meta.status.is_some()
&& head - trailing - (gap + STATUS_W)
>= MIN_LABEL_W;
if show_status {
trailing += gap + STATUS_W;
}
let label_w = (head - trailing).max(0.0);
let display_name = truncate_display_name(base_name(&name));
let text = egui::RichText::new(display_name);
let text = if in_book { text } else { text.weak() };
let mut label = ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(selected, text),
);
// `SelectableLabel` has no truncate
// of its own and lays its text out
// with `Extend`, so `add_sized` does
// not hold it to `label_w` — a long
// file name simply spills out of the
// panel. The wrap mode is what it
// actually reads.
//
// `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 {
row_goal_bar(
ui,
bar_w,
goal,
meta.prose_words,
);
if show_bar {
row_goal_bar(
ui,
BAR_W,
goal,
meta.prose_words,
);
}
}
if let Some(status) = &meta.status {
ui.label(
egui::RichText::new(status_tag(status))
.small()
.weak(),
)
.on_hover_text(format!("Status: {status}"));
if show_status {
ui.add_sized(
[STATUS_W, 20.0],
egui::Label::new(
egui::RichText::new(status_tag(
status,
))
.small()
.weak(),
)
.truncate(),
)
.on_hover_text(format!(
"Status: {status}"
));
}
}
})
.response;
@@ -483,14 +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")
.small()
.weak(),
);
})
.allocate_ui_with_layout(
egui::vec2(row_width, 18.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.add(
egui::Label::new(
egui::RichText::new(
"↥ drop here for the top level",
)
.small()
.weak(),
)
.truncate(),
);
},
)
.response;
let target = full_width_row(ui, &target, "\u{0}root-drop");
if drop_highlight(ui, &target) {
@@ -525,78 +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 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;
}
}
});
}
});
}
}
@@ -671,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;
}
@@ -685,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.
///
+237 -7
View File
@@ -17,20 +17,30 @@ use std::thread;
use std::time::{Duration, Instant};
mod autocomplete;
mod auto_snapshot;
mod beats;
mod characters;
mod corkboard;
mod diff;
mod editor;
mod file_list;
mod find;
mod outline;
mod project;
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.
@@ -186,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>,
@@ -195,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.
@@ -259,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.
@@ -280,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
@@ -318,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>,
@@ -325,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
@@ -372,6 +433,67 @@ pub struct App {
watcher_rx: Option<mpsc::Receiver<WatcherMsg>>,
/// The file watcher thread handle, so it can be stopped when the workspace closes.
watcher_thread: Option<thread::JoinHandle<()>>,
/// Index of Story Bible items for cross-referencing with draft text.
story_bible_index: crate::story_bible::StoryBibleIndex,
/// Whether the Story Bible window is open.
show_story_bible: bool,
/// Currently selected Story Bible item path.
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.
last_snapshot_word_count: usize,
/// Whether the snapshots window is open.
show_snapshots: bool,
}
impl App {
@@ -386,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,
@@ -419,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(),
@@ -429,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(),
@@ -447,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(),
@@ -472,6 +614,30 @@ impl App {
show_project_settings: false,
watcher_rx: None,
watcher_thread: None,
story_bible_index: crate::story_bible::StoryBibleIndex::new(),
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,
};
app.load_spell_dict();
app.open_workspace();
@@ -481,10 +647,20 @@ impl App {
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Ctrl+S saves.
// Ctrl+S saves whatever is being typed in. Usually that is the open
// manuscript file, but the Story Bible editor is a second text area with
// its own file, and sending its keystroke to the manuscript both loses
// the edit and writes a file the user was not looking at.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) {
self.dirty = true;
self.save_current();
match self.story_bible_editor_path.clone() {
Some(path) if self.show_story_bible_editor && self.story_bible_editor_focused => {
self.save_story_bible_item(path);
}
_ => {
self.dirty = true;
self.save_current();
}
}
}
// Ctrl+D folds the panels away for distraction-free writing, and back.
@@ -492,13 +668,25 @@ impl eframe::App for App {
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;
@@ -594,6 +782,26 @@ impl eframe::App for App {
self.characters_window(ctx);
}
if self.show_story_bible {
self.story_bible_window(ctx);
}
if self.show_story_bible_editor {
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);
}
if self.show_manuscript_settings {
self.manuscript_settings_window(ctx);
}
@@ -601,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);
@@ -613,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);
+342 -21
View File
@@ -9,6 +9,145 @@ pub(super) struct OutlineFile {
pub beats: Vec<crate::outline::Beat>,
}
/// A tree node for rendering nested beats in the outline.
#[derive(Debug, PartialEq, Eq)]
enum 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 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 beat in beats {
insert_beat(&mut tree, &beat.path, beat);
}
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.
///
/// `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 } => {
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(&salt)
.default_open(true)
.show(ui, |ui| {
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());
}
}
});
}
}
}
}
/// Count the number of filled and total beats in a tree.
fn count_beat_tree(tree: &[BeatTree]) -> (usize, usize) {
let mut done = 0;
let mut total = 0;
for node in tree {
match node {
BeatTree::Heading { children, .. } => {
let (d, t) = count_beat_tree(children);
done += d;
total += t;
}
BeatTree::Beat { filled, .. } => {
total += 1;
if *filled {
done += 1;
}
}
}
}
(done, total)
}
impl App {
/// The project's outline folder, looked for beside the workspace and up
/// through its ancestors, the same way the character folder is found.
@@ -53,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)
@@ -108,6 +248,7 @@ impl App {
.show(ui, |ui| {
for file in &files {
let (done, total) = crate::outline::progress(&file.beats);
let tree = build_beat_tree(&file.beats);
egui::CollapsingHeader::new(format!(
"{} ({done}/{total})",
file.rel
@@ -118,27 +259,7 @@ impl App {
if ui.link("open this file").clicked() {
open_file = Some(file.rel.clone());
}
let mut section = "";
for beat in &file.beats {
if beat.section != section {
section = &beat.section;
if !section.is_empty() {
ui.label(
egui::RichText::new(section)
.small()
.strong(),
);
}
}
let mark = if beat.filled { "✔" } else { "☐" };
let text =
egui::RichText::new(format!(" {mark} {}", beat.name));
ui.label(if beat.filled {
text
} else {
text.weak()
});
}
render_beat_tree(ui, &tree, &file.rel, &chapters, &mut open_file);
});
}
});
@@ -158,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;
}
}
+46 -32
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 {
"✔ cookiecutter.json found".to_string()
} else {
"✖ no cookiecutter.json in that folder".to_string()
})
.small()
.weak(),
);
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 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")
@@ -503,26 +519,22 @@ impl App {
egui::TextEdit::singleline(
&mut self.config.project_open_subdir,
)
.hint_text("06-First Draft")
.hint_text("05-First Draft")
.desired_width(280.0),
)
.on_hover_text(
"Which folder of a project holds the book. Its files are ordered, numbered and exported; the rest of the project is reference. A leading number is optional, so “First Draft” also matches “06-First Draft”.",
"Which folder of a project holds the book. 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\".",
);
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(),
);
@@ -534,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"]
);
}
}
+42 -2
View File
@@ -196,7 +196,7 @@ impl App {
/// The project's character folder, looked for beside the workspace and then
/// up through its ancestors — in a snowflake project the workspace is
/// `06-First Draft` and the sheets are its sibling `03-Characters`.
/// `05-First Draft` and the sheets are its sibling `03-Story-Bible`.
pub(super) fn characters_dir(&self) -> Option<PathBuf> {
/// How far up to look before giving up; deep enough for the layout,
/// shallow enough not to wander into the home directory.
@@ -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;
}
}
+821
View File
@@ -0,0 +1,821 @@
//! Story Bible breakout editor: a dedicated window for editing Story Bible items.
//!
//! Provides a tree view of Story Bible items on the left and an editor on the right.
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
/// A tree node for the Story Bible tree view.
#[derive(Debug, Clone)]
pub enum BibleTreeNode {
/// A directory/folder node with children
Directory {
name: String,
path: String,
children: Vec<BibleTreeNode>,
},
/// A file/node (leaf)
File {
name: String,
path: String,
},
}
impl BibleTreeNode {
/// Create a directory node.
fn dir(name: String, path: String, children: Vec<BibleTreeNode>) -> Self {
Self::Directory { name, path, children }
}
/// Create a file node.
fn file(name: String, path: String) -> Self {
Self::File { name, path }
}
}
impl App {
/// Build the Story Bible tree structure from the Story Bible directory.
pub(super) fn build_story_bible_tree(&self) -> Vec<BibleTreeNode> {
let story_bible_dir = self.find_story_bible_directory();
let Some(base_dir) = story_bible_dir else {
return Vec::new();
};
let mut root_nodes = Vec::new();
// Read the directory structure
if let Ok(entries) = fs::read_dir(&base_dir) {
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
// Sort entries: directories first, then files, both alphabetically
entries.sort_by(|a, b| {
let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir());
let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir());
if a_is_dir != b_is_dir {
b_is_dir.cmp(&a_is_dir) // dirs first
} else {
a.file_name().cmp(&b.file_name())
}
});
for entry in entries {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files/directories
if file_name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
if is_dir {
// Recursively build subtree
let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = if rel_path.is_empty() {
base_dir.file_name().unwrap_or_else(|| "Story Bible".as_ref()).to_string_lossy().to_string()
} else {
path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string()
};
let children = Self::build_tree_recursive(&path, &base_dir);
root_nodes.push(BibleTreeNode::dir(display_name, rel_path, children));
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
// It's a markdown file
let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
root_nodes.push(BibleTreeNode::file(display_name, rel_path));
}
}
}
root_nodes
}
/// Recursively build tree from a directory.
fn build_tree_recursive(dir_path: &PathBuf, base_dir: &PathBuf) -> Vec<BibleTreeNode> {
let mut children = Vec::new();
if let Ok(entries) = fs::read_dir(dir_path) {
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
// Sort entries: directories first, then files, both alphabetically
entries.sort_by(|a, b| {
let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir());
let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir());
if a_is_dir != b_is_dir {
b_is_dir.cmp(&a_is_dir) // dirs first
} else {
a.file_name().cmp(&b.file_name())
}
});
for entry in entries {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files/directories
if file_name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
if is_dir {
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
let grandchildren = Self::build_tree_recursive(&path, base_dir);
children.push(BibleTreeNode::dir(display_name, rel_path, grandchildren));
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
children.push(BibleTreeNode::file(display_name, rel_path));
}
}
}
children
}
/// Find the Story Bible directory path.
pub(super) fn find_story_bible_directory(&self) -> Option<PathBuf> {
use std::path::Path;
let ws = self.config.workspace.clone();
// Look for Story Bible directory - common locations
let story_bible_dirs = [
"03-Story-Bible",
"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() {
return Some(dir_path.to_path_buf());
}
}
None
}
/// 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;
for node in nodes {
match node {
BibleTreeNode::Directory { name, path, children } => {
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
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,
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);
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();
}
});
}
}
}
clicked_path
}
/// Show the Story Bible breakout editor window.
pub(super) fn story_bible_editor_window(&mut self, ctx: &egui::Context) {
// Build tree structure
let tree = self.build_story_bible_tree();
// Create a window with custom sizing - we want tree on left 1/3, editor on right 2/3
let mut open = self.show_story_bible_editor;
egui::Window::new("Story Bible Editor")
.open(&mut open)
.resizable(true)
.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);
});
self.show_story_bible_editor = open;
// Clear editor state when window is closed
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). 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.vertical(|ui| {
ui.set_width(tree_width);
ui.heading("Story Bible");
ui.separator();
// 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,
&mut add_requested,
&mut rename_requested,
) {
// Load the clicked file
self.load_story_bible_item(&clicked_path);
}
}
});
});
ui.separator();
// Right panel: Editor (2/3 width)
ui.vertical(|ui| {
// Show current file info
if let Some(ref path) = self.story_bible_editor_path {
ui.heading(path);
} else {
ui.heading("Select an item to edit");
}
ui.separator();
// 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() {
if let Some(ref path) = self.story_bible_editor_path {
self.save_story_bible_item(path.clone());
}
}
if ui.button("Close").clicked() {
self.show_story_bible_editor = false;
}
});
});
// 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.
pub(super) fn load_story_bible_item(&mut self, workspace_rel_path: &str) {
let story_bible_dir = self.find_story_bible_directory();
let Some(base_dir) = story_bible_dir else {
self.status = "Story Bible directory not found".to_string();
return;
};
let full_path = base_dir.join(workspace_rel_path);
match fs::read_to_string(&full_path) {
Ok(content) => {
self.story_bible_editor_content = content;
self.story_bible_editor_path = Some(workspace_rel_path.to_string());
}
Err(e) => {
self.status = format!("Could not read {}: {}", workspace_rel_path, e);
}
}
}
/// Save the current Story Bible item.
pub(super) fn save_story_bible_item(&mut self, workspace_rel_path: String) {
let story_bible_dir = self.find_story_bible_directory();
let Some(base_dir) = story_bible_dir else {
self.status = "Story Bible directory not found".to_string();
return;
};
let full_path = base_dir.join(&workspace_rel_path);
match fs::write(&full_path, &self.story_bible_editor_content) {
Ok(_) => {
self.status = format!("Saved: {}", workspace_rel_path);
// Rebuild the Story Bible index to pick up any changes
self.build_story_bible_index();
}
Err(e) => {
self.status = format!("Could not save {}: {}", workspace_rel_path, e);
}
}
}
/// 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;
// If we have a tree, try to select the first item
let tree = self.build_story_bible_tree();
if !tree.is_empty() {
if let Some(first_file) = Self::find_first_file(&tree) {
self.load_story_bible_item(&first_file);
}
}
}
/// Find the first file in the tree (depth-first).
fn find_first_file(nodes: &[BibleTreeNode]) -> Option<String> {
for node in nodes {
match node {
BibleTreeNode::Directory { children, .. } => {
if let Some(file) = Self::find_first_file(children) {
return Some(file);
}
}
BibleTreeNode::File { path, .. } => {
return Some(path.clone());
}
}
}
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);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Story Bible UI: window and reference navigation.
use super::*;
impl App {
/// Show the Story Bible window with indexed items and references.
pub(super) fn story_bible_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_story_bible;
egui::Window::new("Story Bible")
.open(&mut open)
.default_size(egui::vec2(400.0, 500.0))
.show(ctx, |ui| {
self.story_bible_window_contents(ui);
});
self.show_story_bible = open;
if !open {
self.selected_bible_item = None;
}
}
/// Contents of the Story Bible window.
fn story_bible_window_contents(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Search:");
let mut search_query = self.selected_bible_item.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut search_query).changed() {
self.selected_bible_item = Some(search_query);
}
});
ui.separator();
// Filter items based on search query
let all_items = self.story_bible_index.all_items();
let filtered_items: Vec<_> = if let Some(query) = &self.selected_bible_item {
if query.is_empty() {
all_items
} else {
self.story_bible_index.find_by_partial_name(query)
}
} else {
all_items
};
// Show item types as filter tabs
let mut all_types = Vec::new();
for item in &filtered_items {
if !all_types.contains(&item.item_type) {
all_types.push(item.item_type.clone());
}
}
// Sort and display type filters
let mut sorted_types = all_types.clone();
sorted_types.sort();
ui.horizontal_wrapped(|ui| {
if ui.button("All").clicked() {
self.selected_bible_item = None;
}
for t in &sorted_types {
if ui.button(t).clicked() {
// Filter by type - for now just show all and let search filter
}
}
});
ui.separator();
// Clone current_references to avoid borrow issues
let current_refs = self.current_references.clone();
let selected_item = self.selected_bible_item.clone();
// Use a RefCell to allow mutable access inside the closure
// Actually, simpler: use a local variable to track if we need to jump
let mut jump_target: Option<crate::story_bible::StoryBibleReference> = None;
// Display items
egui::ScrollArea::vertical().show(ui, |ui| {
for item in &filtered_items {
let selected = selected_item.as_ref() == Some(&item.path);
if ui
.selectable_label(selected, format!("{}: {}", item.item_type, item.name))
.clicked()
{
self.selected_bible_item = Some(item.path.clone());
}
// Show references to this item in draft
if selected {
ui.indent("bible_item_details", |ui| {
ui.label(format!("Type: {}", item.item_type));
ui.label(format!("Path: {}", item.path));
ui.separator();
// Find references to this item
let item_refs: Vec<_> = current_refs
.iter()
.filter(|r| r.item_id == item.id)
.collect();
if !item_refs.is_empty() {
ui.label(egui::RichText::new(format!(
"{} reference(s) in current file:",
item_refs.len()
)).strong());
for ref_info in &item_refs {
if ui
.button(format!(
"Line {}: {}",
ref_info.line_number, ref_info.identifier
))
.clicked()
{
jump_target = Some((*ref_info).clone());
}
}
} else {
ui.label("No references in current file");
}
});
}
}
if filtered_items.is_empty() {
ui.label("No Story Bible items found");
}
});
// Perform the jump after the closure
if let Some(ref_info) = jump_target {
self.jump_to_reference(&ref_info);
}
}
/// 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.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;
}
}
+131 -124
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,6 +270,18 @@ impl App {
ui.close_menu();
self.open_find(true);
}
if ui
.button("🔎 Search workspace… Ctrl+Shift+F")
.on_hover_text("Find (and optionally replace) across every file")
.clicked()
{
ui.close_menu();
self.open_workspace_search();
}
if ui.button("📸 Snapshots…").clicked() {
ui.close_menu();
self.show_snapshots = true;
}
});
ui.menu_button("Tools", |ui| {
if ui.button("☑ Outline…").clicked() {
@@ -331,6 +292,34 @@ impl App {
ui.close_menu();
self.show_characters = true;
}
if ui.button("📖 Story Bible…").clicked() {
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
@@ -348,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()
@@ -373,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")
@@ -420,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;
}
@@ -432,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;
@@ -447,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;
@@ -573,6 +577,9 @@ impl App {
} else {
self.editor(ui);
}
// Check for auto-snapshot after editor updates
self.check_auto_snapshot();
}
None => {
ui.centered_and_justified(|ui| {
@@ -651,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| {
@@ -663,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 {
(
+364 -29
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));
@@ -93,9 +94,10 @@ impl App {
self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names();
self.load_characters();
self.build_story_bible_index();
self.autocomplete = None;
self.collapsed.clear();
// Ensure the manuscript folder (e.g., 06-First Draft) is always expanded
// Ensure the manuscript folder (e.g., 05-First Draft) is always expanded
if let Some(ref dir) = self.manuscript_dir {
self.collapsed.remove(dir);
}
@@ -283,7 +285,7 @@ impl App {
/// if so, which of its subfolders is the manuscript.
///
/// The test is simply whether it has a child folder named like the
/// configured manuscript folder (`06-First Draft`), matched leniently by
/// configured manuscript folder (`05-First Draft`), matched leniently by
/// [`order::dir_matches`]. `None` means the workspace is itself the
/// manuscript, which is how the app behaved before project mode and remains
/// the right answer for a plain folder of chapters.
@@ -311,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
@@ -339,6 +362,43 @@ impl App {
.collect()
}
/// 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;
if let Some(dir_path) = self.find_story_bible_directory() {
self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path);
return;
}
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);
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);
}
}
}
}
/// Prose words across the whole manuscript, counting the open file from the
/// buffer so the total moves as you type.
pub(super) fn manuscript_words(&self) -> usize {
@@ -416,12 +476,28 @@ impl App {
}
}
/// Lift the header out of the buffer, leaving the prose. Does nothing for a
/// document with no draft marker — there is nothing to collapse.
/// 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) {
if self.header_stash.is_some() {
return;
}
// Try YAML frontmatter first
if let Some((header, body)) = crate::preprocess::split_yaml_frontmatter(&self.buffer) {
let (header, body) = (header.to_string(), body.to_string());
self.header_stash = Some(header);
self.buffer = body;
self.after_view_change();
return;
}
// Fall back to draft marker
let Some((header, body)) =
crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker)
else {
@@ -465,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.
@@ -492,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}"),
}
@@ -528,6 +615,10 @@ 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
// as the way to move a file between folders by typing.
self.rename_input = strip_md(&name).to_string();
@@ -600,6 +691,81 @@ impl App {
self.rebuild_field_names();
}
/// 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) {
// Determine which folder we're in
let current_dir = self
.selected
.and_then(|i| self.files.get(i))
.map(|path| parent_dir(path).to_string())
.unwrap_or_default();
// Check if we're in a First Draft folder (Act 1, Act 2, etc.)
let in_draft_folder = current_dir.starts_with("05-First Draft/Act")
|| current_dir.starts_with("05-First Draft/");
// Extract act number from current directory
let act_num = if in_draft_folder {
// Parse "05-First Draft/Act N" or "05-First Draft/Act N/"
current_dir.split('/').nth(1).and_then(|s| {
s.strip_prefix("Act ").and_then(|n| n.parse::<u32>().ok())
})
} else {
// Default to Act 1
Some(1)
};
let act = act_num.unwrap_or(1);
// Find next available scene number in this act
let existing_scenes: Vec<u32> = self.files.iter()
.filter(|f| {
let path_lower = f.to_lowercase();
if in_draft_folder {
// Check files in current act
path_lower.contains(&format!("act {}/", act))
|| path_lower.contains(&format!("Act {}/", act))
} else {
// Check files in 05-First Draft
path_lower.contains(&format!("05-first draft/act {}/scene", act))
|| path_lower.contains(&format!("05-first draft/Act {}/scene", act))
}
})
.filter_map(|f| {
// Extract scene number from filename
let filename = Path::new(f).file_name().and_then(|n| n.to_str()).unwrap_or("");
let num_str = filename.trim_start_matches("scene").trim_start_matches("_").trim_end_matches(".md");
num_str.parse::<u32>().ok()
})
.collect();
let scene_num = (1..=1000).find(|n| !existing_scenes.contains(n)).unwrap_or(1);
// Default chapter is 1
let chapter = 1;
// Generate scene title
let title = format!("Chapter {}: Scene {}", chapter, scene_num);
let slug = format!("chapter-{}--scene-{}", chapter, scene_num);
// Determine where to create file
let draft_dir = format!("05-First Draft/Act {}", act);
// Scene file
let filename = format!("{}/scene{:02}.md", draft_dir, scene_num);
let content = scene_file_contents(&title, &slug, act, chapter, scene_num);
// Create the merged file
self.insert_new_file(filename.clone(), content);
// Select the new file
let file_idx = self.files.iter().position(|f| *f == filename).unwrap_or(0);
self.selected = Some(file_idx);
self.buffer.clear();
self.dirty = false;
}
/// Write `contents` to a new file, add it to the manuscript order and select
/// it. Shared by the plain and template-backed create paths.
fn insert_new_file(&mut self, name: String, contents: String) {
@@ -876,7 +1042,7 @@ impl App {
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();
@@ -922,7 +1088,7 @@ impl App {
/// 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();
@@ -985,7 +1151,7 @@ impl App {
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.
@@ -1693,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
@@ -1705,15 +1916,123 @@ 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]
fn the_project_root_is_the_folder_the_manuscript_sits_in() {
let ws = Path::new("/books/My Book/06-First Draft");
let ws = Path::new("/books/My Book/05-First Draft");
let project = Path::new("/books/My Book");
// Project mode: the workspace already is the project root, so an
@@ -1760,14 +2079,14 @@ mod tests {
#[test]
fn render_template_expands_every_placeholder() {
let out = render_template(
"# Title: {{name}}\n# Started: {{date}}\n\n{{marker}}\n",
"---\ntitle: {{name}}\n---\n",
"untitled-7",
"### Rough Draft:",
"",
"2026-08-22",
);
assert_eq!(
out,
"# Title: untitled-7\n# Started: 2026-08-22\n\n### Rough Draft:\n"
"---\ntitle: untitled-7\n---\n"
);
}
@@ -1790,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]
@@ -1807,7 +2141,7 @@ mod tests {
assert_eq!(h.slug, None);
assert_eq!(h.pov, None);
assert_eq!(h.goal, None);
// Nothing above the marker leaks into the exported prose.
// Nothing above the --- leaks into the exported prose.
assert_eq!(h.body.trim(), "");
}
@@ -1816,8 +2150,7 @@ mod tests {
fn filled_template_parses_into_header_fields() {
let marker = crate::config::default_marker();
let seeded = render_template(
"# Title: {{name}}\n# Slug: the door\n# POV: Ada\n\
# Word Count Target: 1500 - 2000\n\n{{marker}}\n\nReal prose here.\n",
"---\ntitle: {{name}}\nslug: the door\npov: Ada\nword_count_target: 1500 - 2000\n---\n\nReal prose here.\n",
"chapter-02",
&marker,
"2026-08-22",
@@ -1835,11 +2168,12 @@ mod tests {
let out = render_template(
&crate::config::default_new_file_template(),
"chapter-01",
"### Rough Draft:",
"",
"2026-08-22",
);
assert!(out.starts_with("# Title: chapter-01\n"), "got {out:?}");
assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}");
assert!(out.starts_with("---\n"), "got {out:?}");
assert!(out.contains("title: chapter-01"), "got {out:?}");
assert!(out.contains("---\n"), "got {out:?}");
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
}
@@ -1973,10 +2307,10 @@ mod tests {
let files = v(&[
"00-Scratch Pad/Generated plot points.md",
"03-Characters/ada.md",
"06-First Draft/Act 1/scene01.md",
"06-First Draft/draft_v1.md",
"05-First Draft/Act 1/scene01.md",
"05-First Draft/draft_v1.md",
]);
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(2));
assert_eq!(first_listed(&files, Some("05-First Draft")), Some(2));
}
#[test]
@@ -1991,33 +2325,34 @@ mod tests {
#[test]
fn an_empty_manuscript_falls_back_to_the_first_file() {
let files = v(&["00-Scratch Pad/note.md", "03-Characters/ada.md"]);
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(0));
assert_eq!(first_listed(&files, Some("05-First Draft")), Some(0));
}
#[test]
fn after_a_removal_the_next_listed_file_is_chosen() {
let files = v(&[
"00-Scratch Pad/note.md",
"06-First Draft/a.md",
"06-First Draft/b.md",
"05-First Draft/a.md",
"05-First Draft/b.md",
]);
// Removing index 1 leaves the following manuscript file at index 1.
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 1), Some(1));
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 1), Some(1));
// Removing the last one falls back to the previous manuscript file.
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 3), Some(2));
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 3), Some(2));
}
#[test]
fn a_removal_never_lands_on_a_reference_file() {
let files = v(&["00-Scratch Pad/note.md", "06-First Draft/a.md"]);
let files = v(&["00-Scratch Pad/note.md", "05-First Draft/a.md"]);
// Index 2 is past the end; the search back must skip the scratch pad.
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 2), Some(1));
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 2), Some(1));
}
#[test]
fn removing_the_only_manuscript_file_still_selects_something() {
let files = v(&["00-Scratch Pad/note.md"]);
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 0), Some(0));
assert_eq!(nearest_listed(&[], Some("06-First Draft"), 0), None);
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 0), Some(0));
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()
}
}
+1 -1
View File
@@ -249,7 +249,7 @@ mod tests {
Character {
name: heading_name(text).unwrap_or_default(),
file: PathBuf::from("x.md"),
fields: crate::preprocess::fields(text, "### Rough Draft:"),
fields: crate::preprocess::fields(text, ""),
subtitle: subtitle(text),
}
}
+187 -47
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)]
@@ -164,6 +171,47 @@ pub struct Config {
/// paginated screenplay PDF instead of an ODT.
#[serde(default)]
pub manuscript_screenplay: bool,
/// Whether auto-snapshot (git commit every N words) is enabled.
#[serde(default)]
pub auto_snapshot_enabled: bool,
/// 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.
pub fn default_auto_snapshot_threshold() -> usize {
250
}
/// The toolbar and the file list are what the window opens with; hiding either
@@ -173,26 +221,11 @@ pub fn default_show_toolbar() -> bool {
true
}
/// Default cookiecutter template for **File ▸ New project…**.
/// First tries the bundled template, then falls back to the user's Cookiecutters directory.
pub fn default_project_template() -> PathBuf {
// Try the bundled template first
let bundled = PathBuf::from("templates/snowflake");
if bundled.join("cookiecutter.json").is_file() {
return bundled;
}
// Fallback to user's Cookiecutters directory
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Documents")
.join("Cookiecutters")
.join("snowflake")
}
/// Default subfolder opened after generating a project: the snowflake
/// template's drafting folder, where the chapter files live.
/// template's drafting folder (05-First Draft), where the chapter files live.
/// Contains merged planning and draft content in a single folder.
pub fn default_project_open_subdir() -> String {
"06-First Draft".to_string()
"05-First Draft".to_string()
}
/// Templates ship hooks because they are meant to run; honouring them is the
@@ -217,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(|| {
@@ -224,17 +263,18 @@ pub fn default_projects_dir() -> PathBuf {
})
}
/// Default new-file template: the editorial header fields this app already
/// understands, followed by the draft marker so the body starts below it.
/// Default new-file template: Uses YAML frontmatter for metadata,
/// with everything after the closing --- being the draft content.
pub fn default_new_file_template() -> String {
[
"# Title: {{name}}",
"# Slug:",
"# POV:",
"# Word Count Target:",
"",
"{{marker}}",
"",
"---",
"title: {{name}}",
"slug: ",
"status: draft",
"pov: ",
"characters: []",
"word_count_target: ",
"---",
"",
]
.join("\n")
@@ -281,8 +321,9 @@ pub fn default_languagetool_language() -> String {
}
/// Default header/body separator, matching the manuscript drafting convention.
/// Now empty by default since we use YAML frontmatter.
pub fn default_marker() -> String {
"### Rough Draft:".to_string()
"".to_string()
}
impl Default for Config {
@@ -312,8 +353,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(),
@@ -325,12 +365,18 @@ 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(),
manuscript_title_page: false,
manuscript_standard_format: false,
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(),
}
}
}
@@ -394,6 +440,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) {
@@ -439,7 +499,7 @@ mod tests {
assert!(cfg.show_preview);
// The new field is filled in from its default rather than left blank.
assert_eq!(cfg.new_file_template, default_new_file_template());
assert!(cfg.new_file_template.contains("{{marker}}"));
assert!(cfg.new_file_template.contains("---"));
// Likewise the Gitea pair. `use_ssh` defaulting to *true* is the point:
// a `#[serde(default)]` bool would come back false and quietly attach
// every repository over HTTPS.
@@ -455,7 +515,6 @@ mod tests {
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"show_preview": true,
"draft_marker": "### Rough Draft:",
"editor_zoom": 10.0,
"languagetool_host": "lt.example.com",
"mistral_api_key": "secret",
@@ -511,23 +570,23 @@ 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
/// template's drafting folder, and the name contains a space — which has to
/// survive being stored and joined onto the project path.
/// template's drafting folder (merged planning + draft), and the name contains
/// a space — which has to survive being stored and joined onto the project path.
#[test]
fn the_default_project_subdir_is_the_drafting_folder() {
assert_eq!(default_project_open_subdir(), "06-First Draft");
assert_eq!(default_project_open_subdir(), "05-First Draft");
let joined = PathBuf::from("/tmp/The Winter Gate").join(default_project_open_subdir());
assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/06-First Draft"));
assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/05-First Draft"));
}
/// A config.json written before screenplay projects existed. Same risk as
@@ -548,6 +607,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]
@@ -560,4 +670,34 @@ mod tests {
let cfg: Config = serde_json::from_str(saved).expect("must parse");
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 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);
}
}
+5 -4
View File
@@ -7,7 +7,7 @@ pub struct GitOutcome {
pub log: String,
}
fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) {
pub fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) {
let output = Command::new("git")
.current_dir(workspace)
.args(args)
@@ -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.
+402 -12
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 (chapter
//! metadata, the draft-marker header, 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,
@@ -136,24 +506,26 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
ui,
"cs-header",
&[
("### Rough Draft:", "The draft marker — everything above it is your working header."),
("---", "YAML frontmatter delimiter — metadata between --- lines is not exported."),
("title: ...", "Chapter title (used for export, not included in draft content)."),
("slug: ...", "Optional caption shown beneath the chapter title."),
("pov: ...", "Point of view character for this chapter."),
("word_count_target: ...", "Target word count (e.g., 2000 or 1500-2000)."),
("<!-- note -->", "A comment; dropped on export (may span multiple lines)."),
],
);
note(
ui,
"The draft marker text is configurable in the top bar. Everything above the \
marker line is treated as notes and left out of the export; leave the marker \
blank to export the whole file. Comments are removed everywhere.",
"Files use YAML frontmatter for metadata. Everything between --- delimiters is \
metadata and is not included in the export. Comments are removed everywhere. \
Chapter titles are automatically numbered based on file order in the file list.",
);
note(
ui,
"Typing a header field name above the marker (e.g. “## PO”) pops up an \
"Typing a YAML field name (e.g. \"title:\" or \"pov:\") in the frontmatter pops up an \
autocomplete — Tab/Enter to accept, ↑/↓ to choose, Esc to dismiss. It \
suggests common fields plus ones learned from your other files.",
);
section(ui, "Spelling & grammar");
body(
ui,
"Spelling is checked live and offline: misspellings are underlined in red \
@@ -233,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);
}
}
+3 -1
View File
@@ -5,7 +5,6 @@
mod app;
mod characters;
mod config;
mod cookiecutter;
mod fountain;
mod gitea;
mod gitsync;
@@ -16,10 +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;
+47 -11
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.
@@ -576,10 +587,10 @@ mod tests {
assert!(dir_matches("Archive", "Archive"));
assert!(dir_matches("archive", "ARCHIVE"));
assert!(dir_matches("03-Characters", "Characters"));
assert!(dir_matches("06-First Draft", "06-First Draft"));
assert!(dir_matches("06-First Draft", "First Draft"));
assert!(dir_matches("6 First Draft", "First Draft"));
assert!(dir_matches("06_First Draft", "First Draft"));
assert!(dir_matches("05-First Draft", "05-First Draft"));
assert!(dir_matches("05-First Draft", "First Draft"));
assert!(dir_matches("5 First Draft", "First Draft"));
assert!(dir_matches("05_First Draft", "First Draft"));
}
#[test]
@@ -592,12 +603,37 @@ 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(
"hidden",
&[
"06-First Draft/ch-01.md",
"05-First Draft/ch-01.md",
"03-Characters/ada.md",
"10-Archive/old/dead-draft.md",
"10-Archive/also-dead.md",
@@ -609,7 +645,7 @@ mod tests {
let hidden = vec!["Archive".to_string()];
let mut kept = list_markdown_files(&ws, &hidden);
kept.sort();
assert_eq!(kept, v(&["03-Characters/ada.md", "06-First Draft/ch-01.md"]));
assert_eq!(kept, v(&["03-Characters/ada.md", "05-First Draft/ch-01.md"]));
let _ = std::fs::remove_dir_all(&ws);
}
@@ -624,16 +660,16 @@ mod tests {
fn the_manuscript_folder_is_found_by_role_name() {
let ws = scratch(
"project",
&["06-First Draft/ch-01.md", "03-Characters/ada.md"],
&["05-First Draft/ch-01.md", "03-Characters/ada.md"],
);
// The configured name matches with or without its number.
assert_eq!(
child_dir_matching(&ws, "06-First Draft").as_deref(),
Some("06-First Draft")
child_dir_matching(&ws, "05-First Draft").as_deref(),
Some("05-First Draft")
);
assert_eq!(
child_dir_matching(&ws, "First Draft").as_deref(),
Some("06-First Draft")
Some("05-First Draft")
);
// A folder the project does not have is not invented.
assert_eq!(child_dir_matching(&ws, "09-Covers"), None);
@@ -646,7 +682,7 @@ mod tests {
#[test]
fn a_plain_chapter_folder_is_not_a_project_root() {
let ws = scratch("plain", &["ch-01.md", "ch-02.md"]);
assert_eq!(child_dir_matching(&ws, "06-First Draft"), None);
assert_eq!(child_dir_matching(&ws, "05-First Draft"), None);
let _ = std::fs::remove_dir_all(&ws);
}
}
+52 -10
View File
@@ -25,29 +25,36 @@ pub struct Beat {
pub name: String,
/// Whether any prose has been written for it.
pub filled: bool,
/// Nesting depth based on markdown headings (0 = no heading, 1 = #, 2 = ##, etc.)
pub depth: usize,
/// Full path of parent headings, e.g., ["Act 1", "Chapter 1"] for a scene in Act 1, Chapter 1
pub path: Vec<String>,
}
/// Read an outline file's beats and their state.
///
/// A beat is "filled" when there is ordinary text between its prompt comment
/// and the next prompt or heading — which is exactly where you would type it.
///
/// Heading hierarchy is tracked to support nesting. Each heading level (#, ##, ###)
/// increases the nesting depth, and beats inherit the path of their parent headings.
pub fn beats(markdown: &str) -> Vec<Beat> {
let mut out: Vec<Beat> = Vec::new();
let mut section = String::new();
let mut rest = markdown;
// Track heading hierarchy: stack of (heading_text, depth) pairs
let mut heading_stack: Vec<(String, usize)> = Vec::new();
// Text seen since the last prompt, which decides whether it is filled.
let mut pending_prose = String::new();
while let Some(start) = rest.find("<!--") {
let before = &rest[..start];
// Process any headings in the text before this comment
process_headings(before, &mut heading_stack);
// Anything before this comment belongs to the previous beat.
pending_prose.push_str(before);
if let Some(heading) = last_heading(before) {
// A heading closes the previous beat and opens a new section.
settle(&mut out, &mut pending_prose);
section = heading;
}
settle(&mut out, &mut pending_prose);
let Some(end) = rest[start + 4..].find("-->") else {
@@ -55,23 +62,58 @@ pub fn beats(markdown: &str) -> Vec<Beat> {
};
let body = &rest[start + 4..start + 4 + end];
if let Some(name) = prompt_name(body) {
// Build path from heading stack
let path: Vec<String> = heading_stack.iter().map(|(h, _)| h.clone()).collect();
let depth = heading_stack.len();
let section = if !heading_stack.is_empty() {
heading_stack.last().unwrap().0.clone()
} else {
String::new()
};
out.push(Beat {
section: section.clone(),
name,
filled: false,
depth,
path,
});
}
rest = &rest[start + 4 + end + 3..];
}
pending_prose.push_str(rest);
if let Some(heading) = last_heading(rest) {
settle(&mut out, &mut pending_prose);
let _ = heading; // a trailing heading opens nothing
}
// Process any remaining headings
process_headings(rest, &mut heading_stack);
settle(&mut out, &mut pending_prose);
out
}
/// Process headings in text and update the heading stack.
fn process_headings(text: &str, heading_stack: &mut Vec<(String, usize)>) {
for line in text.lines() {
let trimmed = line.trim();
if !trimmed.starts_with('#') {
continue;
}
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 || hashes > 6 {
continue;
}
let name = trimmed[hashes..].trim();
if name.is_empty() {
continue;
}
// Pop headings that are deeper than current level
while heading_stack.len() >= hashes {
heading_stack.pop();
}
// Push current heading
heading_stack.push((name.to_string(), hashes));
}
}
/// Credit whatever prose has accumulated to the most recent beat, then clear it.
fn settle(beats: &mut [Beat], pending: &mut String) {
if has_prose(pending) {
+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());
}
}
+382 -2
View File
@@ -110,6 +110,30 @@ pub fn split_at_marker<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a
None
}
/// 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(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;
}
let mut end = opening.len();
for line in lines {
end += line.len();
if line.trim() == "---" {
return Some(text.split_at(end));
}
}
None
}
/// One `#* Name: value` line from a document's editorial header.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Field {
@@ -131,6 +155,13 @@ pub struct Field {
/// mistaken for card metadata.
pub fn fields(markdown: &str, marker: &str) -> Vec<Field> {
let text = strip_comments(markdown);
// A file with frontmatter has said everything it has to say by the closing
// `---`, and reading on past it takes prose for metadata: such a file has no
// draft marker to stop at, so scanning the whole document turned a line of
// dialogue like `Bixby: run` into a field.
if let Some((frontmatter, _)) = split_yaml_frontmatter(&text) {
return yaml_fields(frontmatter);
}
let marker_norm = marker.trim().to_ascii_lowercase();
let mut out = Vec::new();
for line in text.lines() {
@@ -156,6 +187,97 @@ pub fn field(markdown: &str, marker: &str, name: &str) -> Option<String> {
.filter(|v| !v.is_empty())
}
/// Every key of a document's YAML frontmatter as a [`Field`], so the status
/// column, the character index and the scene card read frontmatter and a legacy
/// `## POV:` header through one interface.
///
/// Lists are folded into the comma-separated form those readers already speak,
/// so both
///
/// ```text
/// characters:
/// - Bixby
/// - Mom
/// ```
///
/// and `characters: [Bixby, Mom]` arrive as `Bixby, Mom` — and `characters: []`
/// as nothing, rather than as a character called `[]`.
fn yaml_fields(frontmatter: &str) -> Vec<Field> {
let mut out: Vec<Field> = Vec::new();
// Only a key written with nothing after it can be opening a block list, so
// a stray `- ` cannot append itself to a scalar above it.
let mut collecting = false;
for line in frontmatter.lines() {
if line.trim() == "---" {
continue;
}
if let Some(item) = block_list_item(line) {
if collecting {
if let Some(last) = out.last_mut() {
if !last.value.is_empty() {
last.value.push_str(", ");
}
last.value.push_str(&item);
}
}
continue;
}
if let Some((name, value)) = parse_yaml_field(line) {
let value = flow_list_items(&value);
collecting = value.is_empty();
out.push(Field { name, value });
}
}
out
}
/// Look one frontmatter key up, trying each alias in turn. Empty reads as absent,
/// the same way [`field`] treats an unfilled `## POV:`.
fn yaml_value(fields: &[Field], names: &[&str]) -> Option<String> {
names.iter().find_map(|name| {
fields
.iter()
.find(|f| f.name.eq_ignore_ascii_case(name))
.map(|f| f.value.clone())
.filter(|v| !v.is_empty())
})
}
/// The item of a `- item` line, with any quotes taken off.
fn block_list_item(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix('-')?;
// `-value` is a scalar, not a list item; YAML wants the space.
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None;
}
let item = unquote(rest.trim());
(!item.is_empty()).then_some(item)
}
/// `[Bixby, Mom]` as `Bixby, Mom` and `[]` as nothing. Anything that is not a
/// flow list comes back as it went in.
fn flow_list_items(value: &str) -> String {
let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
return value.to_string();
};
inner
.split(',')
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty())
.collect::<Vec<_>>()
.join(", ")
}
/// Strip one pair of matching surrounding quotes.
fn unquote(value: &str) -> String {
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
/// Split a comma-separated field value into its items, dropping blanks — the
/// form `## Characters: Bixby, Mom, Kayla` uses.
pub fn value_items(value: &str) -> Vec<String> {
@@ -247,13 +369,41 @@ fn meta_line(line: &str) -> Option<(Meta, String)> {
}
/// Pre-process one markdown document, splitting header metadata from the body.
/// `marker` is the header/body separator line (e.g. `### Rough Draft:`); an empty
/// marker disables header-block splitting.
/// Tries YAML frontmatter first, then falls back to the legacy marker-based header.
/// `marker` is the legacy header/body separator line (e.g. `### Rough Draft:`); an empty
/// marker disables marker-based splitting, but YAML frontmatter is still parsed.
pub fn parse(markdown: &str, marker: &str) -> Header {
let cleaned = strip_comments(markdown);
let lines: Vec<&str> = cleaned.lines().collect();
let marker_norm = marker.trim().to_ascii_lowercase();
// 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: yaml_value(&fields, &["title"]),
slug: yaml_value(&fields, &["slug"]),
pov: yaml_value(&fields, &["pov"]),
goal,
body: body.trim().to_string(),
};
}
// Fall back to legacy marker-based parsing
let marker_idx = if marker_norm.is_empty() {
None
} else {
@@ -311,6 +461,94 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
}
}
/// Parse a YAML field line like `key: value` or `key: "value"`.
/// Returns (key, value) if the line looks like a YAML field.
/// Strips inline comments (everything after # that's not in quotes).
fn parse_yaml_field(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
// Skip empty lines and full-line comments
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
// Strip inline comments first (everything after # that's not in quotes)
let mut in_quote = false;
let mut quote_char = '\0';
let mut comment_pos = None;
for (i, c) in trimmed.char_indices() {
match c {
'"' | '\'' => {
if in_quote && c == quote_char {
in_quote = false;
quote_char = '\0';
} else if !in_quote {
in_quote = true;
quote_char = c;
}
}
'#' => {
if !in_quote && comment_pos.is_none() {
comment_pos = Some(i);
}
}
_ => {}
}
}
// Trim at the comment position if found
let trimmed_no_comment = if let Some(pos) = comment_pos {
trimmed[..pos].trim()
} else {
trimmed
};
if trimmed_no_comment.is_empty() {
return None;
}
// Find the first colon that's not part of a quoted string
let mut in_quote = false;
let mut quote_char = '\0';
let mut colon_pos = None;
for (i, c) in trimmed_no_comment.char_indices() {
match c {
'"' | '\'' => {
if in_quote && c == quote_char {
in_quote = false;
quote_char = '\0';
} else if !in_quote {
in_quote = true;
quote_char = c;
}
}
':' => {
if !in_quote && colon_pos.is_none() {
colon_pos = Some(i);
}
}
_ => {}
}
}
let colon_pos = colon_pos?;
let key = trimmed_no_comment[..colon_pos].trim().to_string();
let value = trimmed_no_comment[colon_pos + 1..].trim();
// 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
} else {
Some((key, value))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -409,6 +647,148 @@ mod tests {
let md2 = "### Rough Draft:\n\nPOV of the crowd was tense.";
assert_eq!(parse(md2, MARKER).pov, None);
}
#[test]
fn frontmatter_splits_on_exact_byte_offsets() {
// The fold cuts the buffer here and glues it back on save, so the split
// point has to be right to the byte. Measuring trimmed lines and adding
// one for a newline drifted on a padded delimiter and on CRLF.
for text in [
"---\ntitle: x\n---\n\nProse.\n",
"--- \ntitle: x\n--- \n\nProse.\n",
"---\r\ntitle: x\r\n---\r\n\r\nProse.\r\n",
"---\ntitle: x\n---",
] {
let (header, body) = split_yaml_frontmatter(text).expect("frontmatter");
assert_eq!(
format!("{header}{body}"),
text,
"the two halves must put the file back together"
);
assert!(
header.trim_end().ends_with("---"),
"header stops at the closing delimiter: {header:?}"
);
assert!(
!body.contains("title:"),
"the header must not leak into the body: {body:?}"
);
}
}
#[test]
fn a_document_without_a_closing_delimiter_has_no_frontmatter() {
assert!(split_yaml_frontmatter("---\ntitle: x\n\nProse.\n").is_none());
assert!(split_yaml_frontmatter("Prose, starting right away.\n").is_none());
assert!(split_yaml_frontmatter("").is_none());
}
#[test]
fn frontmatter_lists_read_as_items() {
// Both YAML list forms have to reach `value_items`, which is what the
// Characters window matches scenes with. A block list used to read as an
// empty value (so no scene ever matched) and `[]` as one character
// literally called "[]".
let block = "---\ncharacters:\n - Bixby\n - \"Mom\"\nsetting: kitchen\n---\n\nProse.\n";
assert_eq!(
field(block, "### Rough Draft:", "characters").as_deref(),
Some("Bixby, Mom")
);
assert_eq!(
value_items(&field(block, "", "characters").unwrap()),
["Bixby", "Mom"]
);
// The key after the list is still its own field.
assert_eq!(field(block, "", "setting").as_deref(), Some("kitchen"));
let flow = "---\ncharacters: [Bixby, \"Mom\"]\n---\n\nProse.\n";
assert_eq!(field(flow, "", "characters").as_deref(), Some("Bixby, Mom"));
let empty = "---\ncharacters: []\n---\n\nProse.\n";
assert_eq!(field(empty, "", "characters"), None);
}
#[test]
fn frontmatter_keys_may_carry_underscores() {
// `field_line`'s bare form allows only letters, so snake_case keys were
// invisible to the status column and everything else reading `fields`.
let text = "---\nstatus: draft\nword_count_target: 2000\n---\n\nProse.\n";
assert_eq!(field(text, "", "status").as_deref(), Some("draft"));
assert_eq!(
field(text, "", "word_count_target").as_deref(),
Some("2000")
);
}
#[test]
fn prose_below_the_frontmatter_is_not_metadata() {
// A frontmatter file has no draft marker to stop the scan, so a line of
// dialogue used to be read as a field.
let text = "---\npov: Bixby\n---\n\nBixby: run, and don't look back.\n";
assert_eq!(field(text, "", "pov").as_deref(), Some("Bixby"));
assert_eq!(field(text, "", "Bixby"), None);
assert_eq!(fields(text, "").len(), 1);
}
#[test]
fn a_min_max_pair_sets_the_goal() {
// Documented alongside `word_count_target`, and more specific than it.
let pair = "---\nword_count_target: 2000\nword_count_min: 1800\nword_count_max: 2200\n---\n\nProse.\n";
let goal = parse(pair, "").goal.expect("a goal");
assert_eq!((goal.min, goal.max), (1800, 2200));
// On its own, the target still stands.
let target = "---\nword_count_target: 1500 - 2000\n---\n\nProse.\n";
let goal = parse(target, "").goal.expect("a goal");
assert_eq!((goal.min, goal.max), (1500, 2000));
}
#[test]
fn frontmatter_body_is_only_the_prose() {
// What lands in the word count and in the export.
let text = "---\ntitle: \"The Gate\"\nslug: in which the door will not open\n---\n\nThe prose begins here.\n";
let h = parse(text, "### Rough Draft:");
assert_eq!(h.title.as_deref(), Some("The Gate"));
assert_eq!(h.slug.as_deref(), Some("in which the door will not open"));
assert_eq!(h.body, "The prose begins here.");
}
#[test]
fn parses_yaml_frontmatter() {
let md = "---\ntitle: The Gate\nslug: an opening\npov: Bixby\nword_count_target: 1500 - 2000\n---\n\nThe prose begins here.\n\n## A scene";
let h = parse(md, "");
assert_eq!(h.title.as_deref(), Some("The Gate"));
assert_eq!(h.slug.as_deref(), Some("an opening"));
assert_eq!(h.pov.as_deref(), Some("Bixby"));
assert_eq!(h.goal, Some(WordGoal { min: 1500, max: 2000 }));
assert!(h.body.starts_with("The prose begins here."));
assert!(h.body.contains("## A scene"));
}
#[test]
fn parses_yaml_frontmatter_with_quoted_values() {
let md = "---\ntitle: \"The Gate\"\nslug: \"an opening\"\n---\n\nProse here.";
let h = parse(md, "");
assert_eq!(h.title.as_deref(), Some("The Gate"));
assert_eq!(h.slug.as_deref(), Some("an opening"));
}
#[test]
fn parses_yaml_frontmatter_with_comments() {
let md = "---\n# This is a comment
title: The Gate # inline comment\nslug: an opening\n---\n\nProse here.";
let h = parse(md, "");
assert_eq!(h.title.as_deref(), Some("The Gate"));
assert_eq!(h.slug.as_deref(), Some("an opening"));
}
#[test]
fn falls_back_to_marker_if_no_yaml_frontmatter() {
let md = "# Title: The Gate\n# Slug: an opening\n### Rough Draft:\n\nThe prose begins here.";
let h = parse(md, "### Rough Draft:");
assert_eq!(h.title.as_deref(), Some("The Gate"));
assert_eq!(h.slug.as_deref(), Some("an opening"));
assert!(h.body.starts_with("The prose begins here."));
}
}
#[cfg(test)]
+154 -17
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()
));
}
@@ -110,14 +192,28 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
.collect::<String>()) // Simplified title case
});
// Capitalize filter - capitalizes the first letter of each word
env.add_filter("capitalize", |value: String| -> Result<String, minijinja::Error> {
Ok(value.chars()
.enumerate()
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
.collect::<String>())
});
// capitalizes filter (plural) - same as capitalize but handles the common template usage
env.add_filter("capitalizes", |value: String| -> Result<String, minijinja::Error> {
Ok(value.chars()
.enumerate()
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
.collect::<String>())
});
// Get the context from variables
let context = config.variables.to_context();
// 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 {
@@ -135,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
@@ -293,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",
@@ -334,14 +427,58 @@ mod tests {
assert!(readme_content.contains("MiniJinja Test Project"));
assert!(readme_content.contains("A project generated with MiniJinja"));
// Check that the 06-First Draft subdirectory exists (default manuscript folder)
// Check that the 05-First Draft subdirectory exists (default manuscript folder)
let subdir = crate::config::default_project_open_subdir();
assert!(
project.join(&subdir).is_dir(),
"expected a {subdir} folder in {project:?}"
);
// Verify old directories (05-Plot, 06-First Draft) are NOT created
// (they were merged into 05-First Draft)
assert!(
!project.join("05-Plot").is_dir(),
"05-Plot should not exist (merged into 05-First Draft)"
);
assert!(
!project.join("06-First Draft").is_dir(),
"06-First Draft should not exist (merged into 05-First Draft)"
);
// 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");
}
}
+867
View File
@@ -0,0 +1,867 @@
//! Story Bible indexing and linking functionality.
//!
//! This module provides functionality to index Story Bible items (characters,
//! locations, lore, etc.) and create bidirectional links between Story Bible
//! entries and draft text.
use std::collections::HashMap;
use std::path::Path;
/// A reference to a Story Bible item found in draft text.
#[derive(Debug, Clone)]
pub struct StoryBibleReference {
/// The file path of the draft file containing this reference.
pub file_path: String,
/// The line number where the reference appears (1-indexed).
pub line_number: usize,
/// 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,
}
/// Information about a Story Bible item extracted from its file.
#[derive(Debug, Clone)]
pub struct StoryBibleItem {
/// Workspace-relative path to the item's file.
pub path: String,
/// The item's display name.
pub name: String,
/// The item's unique identifier (from ID field or filename).
pub id: String,
/// The item's type (character, location, lore, etc.).
pub item_type: String,
/// Alternative names for this item (from aliases field in YAML frontmatter).
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.
#[derive(Debug, Default)]
pub struct StoryBibleIndex {
/// Map from lowercase identifier to item info.
pub items_by_id: HashMap<String, StoryBibleItem>,
/// Map from lowercase name to item info.
pub items_by_name: HashMap<String, StoryBibleItem>,
/// Map from lowercase alias to item info.
pub items_by_alias: HashMap<String, StoryBibleItem>,
}
impl StoryBibleIndex {
/// Create a new empty index.
pub fn new() -> Self {
Self {
items_by_id: HashMap::new(),
items_by_name: HashMap::new(),
items_by_alias: HashMap::new(),
}
}
/// Build index from Story Bible directory.
///
/// Scans all markdown files in the Story Bible folder and extracts
/// item information from YAML frontmatter or filename.
pub fn build(story_bible_dir: &Path) -> Self {
let mut index = Self::new();
if !story_bible_dir.exists() {
return index;
}
// Walk through all markdown files in the Story Bible directory
for entry in walkdir::WalkDir::new(story_bible_dir)
.follow_links(true)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let relative_path = path
.strip_prefix(story_bible_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
// Read the file and extract metadata
if let Ok(content) = std::fs::read_to_string(path) {
if let Some(item) = extract_story_bible_item(&relative_path, &content) {
// Index by ID (lowercase for case-insensitive lookup)
index
.items_by_id
.insert(item.id.to_lowercase(), item.clone());
// Index by name (lowercase for case-insensitive lookup)
index
.items_by_name
.insert(item.name.to_lowercase(), item.clone());
// Index by aliases (lowercase for case-insensitive lookup)
for alias in &item.aliases {
index
.items_by_alias
.insert(alias.to_lowercase(), item.clone());
}
}
}
}
index
}
/// Find an item by its identifier.
pub fn find_by_id(&self, id: &str) -> Option<&StoryBibleItem> {
self.items_by_id.get(&id.to_lowercase())
}
/// Find an item by its name.
pub fn find_by_name(&self, name: &str) -> Option<&StoryBibleItem> {
self.items_by_name.get(&name.to_lowercase())
}
/// Find an item by one of its aliases.
pub fn find_by_alias(&self, alias: &str) -> Option<&StoryBibleItem> {
self.items_by_alias.get(&alias.to_lowercase())
}
/// Find all items matching a partial name or alias.
pub fn find_by_partial_name(&self, query: &str) -> Vec<&StoryBibleItem> {
let query_lower = query.to_lowercase();
self.items_by_name
.values()
.filter(|item| {
item.name.to_lowercase().contains(&query_lower)
|| item
.aliases
.iter()
.any(|a| a.to_lowercase().contains(&query_lower))
})
.collect()
}
/// Get all items in the index.
pub fn all_items(&self) -> Vec<&StoryBibleItem> {
self.items_by_id.values().collect()
}
}
/// Extract Story Bible item information from a file.
fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryBibleItem> {
// Try to extract from YAML frontmatter first
if let Some(item) = extract_from_frontmatter(relative_path, content) {
return Some(item);
}
// Fallback: extract from filename
let path_obj = Path::new(relative_path);
let filename = path_obj.file_stem().and_then(|s| s.to_str())?;
// Determine type from directory structure
let item_type = path_obj
.parent()
.and_then(|p| p.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "unknown".to_string());
// Use filename as name
let name = filename.replace('_', " ").replace('-', " ");
Some(StoryBibleItem {
path: relative_path.to_string(),
name: name.clone(),
id: name.to_lowercase().replace(' ', "_"),
item_type,
aliases: Vec::new(),
definition_line: 0,
summary: None,
})
}
/// Extract item information from YAML frontmatter.
fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryBibleItem> {
let lines: Vec<&str> = content.lines().collect();
// Find frontmatter boundaries
let start = lines.iter().position(|line| line.trim() == "---")?;
let end = lines[start + 1..]
.iter()
.position(|line| line.trim() == "---")
.map(|pos| start + 1 + pos)?;
if end <= start + 1 {
return None;
}
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();
// `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:") {
item_type = Some(trimmed["type:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("aliases:") {
// 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, 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())
.map(|s| s.replace('_', " ").replace('-', " "))
.unwrap_or_else(|| "Unknown".to_string())
});
// Use ID or sanitized name as fallback
let id = id.unwrap_or_else(|| {
name.to_lowercase().replace(' ', "_").replace('-', "_")
});
// Use type from frontmatter or directory
let item_type = item_type.unwrap_or_else(|| {
Path::new(relative_path)
.parent()
.and_then(|p| p.to_str())
.map(|s| {
let dir_name = s.to_lowercase();
if dir_name.contains("character") || dir_name.contains("cast") {
"character".to_string()
} else if dir_name.contains("location") || dir_name.contains("setting") {
"location".to_string()
} else if dir_name.contains("lore") || dir_name.contains("world") {
"lore".to_string()
} else {
dir_name
}
})
.unwrap_or_else(|| "unknown".to_string())
});
Some(StoryBibleItem {
path: relative_path.to_string(),
name,
id,
item_type,
aliases,
definition_line: start + 1,
summary,
})
}
/// Parse aliases from YAML frontmatter value.
/// Supports both list syntax: aliases: ["Alias 1", "Alias 2"]
/// and comma-separated: aliases: Alias1, Alias2
fn parse_aliases(value: &str) -> Vec<String> {
if value.is_empty() {
return Vec::new();
}
// Check if it's a YAML list: ["...", "..."]
if value.trim().starts_with('[') && value.trim().ends_with(']') {
let inner = value.trim()[1..value.trim().len() - 1].trim();
// Parse items from the list
let mut aliases = Vec::new();
let mut in_quote = false;
let mut quote_char = '\0';
let mut current = String::new();
for c in inner.chars() {
match c {
'"' | '\'' => {
if in_quote && c == quote_char {
in_quote = false;
quote_char = '\0';
if !current.is_empty() {
aliases.push(current.clone());
current.clear();
}
} else if !in_quote {
in_quote = true;
quote_char = c;
}
}
',' => {
if !in_quote {
if !current.is_empty() {
aliases.push(current.clone());
current.clear();
}
} else {
current.push(c);
}
}
_ => {
if in_quote {
current.push(c);
}
}
}
}
// Add the last item
if !current.is_empty() {
aliases.push(current);
}
return aliases;
}
// Comma-separated format
value
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// 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.
///
/// 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> {
// (start, end, item_id, identifier) — accepted spans, explicit first.
let mut accepted: Vec<(usize, usize, String, String)> = Vec::new();
// 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;
}
} 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 != '-')
.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);
}
// 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()));
}
}
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.
pub fn link_to_item(item: &StoryBibleItem, display_text: Option<&str>) -> String {
let text = display_text.unwrap_or(&item.name);
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::*;
#[test]
fn test_extract_from_frontmatter() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
---
Some content here."#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.id, "character_01");
assert_eq!(item.name, "John Doe");
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 = {
let mut idx = StoryBibleIndex::new();
idx.items_by_id.insert(
"john_doe".to_string(),
StoryBibleItem {
path: "Characters/john_doe.md".to_string(),
name: "John Doe".to_string(),
id: "john_doe".to_string(),
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
},
);
idx.items_by_name.insert(
"john doe".to_string(),
StoryBibleItem {
path: "Characters/john_doe.md".to_string(),
name: "John Doe".to_string(),
id: "john_doe".to_string(),
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
},
);
idx
};
let refs = find_references("Some text with [[John Doe]] here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "John Doe");
}
#[test]
fn test_find_references_mentions() {
let index = {
let mut idx = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/jane.md".to_string(),
name: "Jane".to_string(),
id: "jane".to_string(),
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);
idx
};
let refs = find_references("Some text mentioning @Jane here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Jane");
}
#[test]
fn test_find_by_alias() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
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());
index.items_by_alias.insert("johnny".to_string(), item.clone());
index.items_by_alias.insert("js".to_string(), item);
assert!(index.find_by_alias("Johnny").is_some());
assert!(index.find_by_alias("JS").is_some());
assert!(index.find_by_alias("johnny").is_some());
assert!(index.find_by_alias("nonexistent").is_none());
}
#[test]
fn test_find_by_partial_name_with_aliases() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
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);
let results = index.find_by_partial_name("Johnny");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "John Smith");
let results = index.find_by_partial_name("JS");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "John Smith");
}
#[test]
fn test_find_references_with_aliases() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
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());
index.items_by_alias.insert("johnny".to_string(), item);
let refs = find_references("Some text with [[Johnny]] here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Johnny");
let refs = find_references("Some text with @Johnny here.", &index);
assert_eq!(refs.len(), 1);
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"]"#);
assert_eq!(aliases, vec!["Alias 1", "Alias 2", "Alias 3"]);
}
#[test]
fn test_parse_aliases_comma_separated() {
let aliases = parse_aliases("Alias1, Alias2, Alias3");
assert_eq!(aliases, vec!["Alias1", "Alias2", "Alias3"]);
}
#[test]
fn test_parse_aliases_empty() {
let aliases = parse_aliases("");
assert!(aliases.is_empty());
}
#[test]
fn test_extract_from_frontmatter_with_aliases() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
aliases: ["Johnny", "JS"]
---
Some content here."#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.id, "character_01");
assert_eq!(item.name, "John Doe");
assert_eq!(item.item_type, "character");
assert_eq!(item.aliases, vec!["Johnny", "JS"]);
}
}
+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,20 +0,0 @@
<!-- A quick‑look table summarizing all characters (goal, motivation, stakes, etc.) -->
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
<th>Description</th>
<th>Motivation</th>
</tr>
</thead>
<tbody>
<tr>
<td>John Doe</td>
<td>30</td>
<td>Average Joe</td>
<td>Just wants a cheeseburger.</td>
</tr>
</tbody>
</table>
@@ -1,13 +0,0 @@
# Name
### Slug
Age:
Description:
Motivation:
Single Quote:
### Summary:
@@ -1,13 +0,0 @@
# Name
### Slug
Age:
Description:
Motivation:
Single Quote:
### Summary:
@@ -0,0 +1,60 @@
---
title: Characters Index
type: index
---
# Characters Index
*All characters in the story, organized by role and status*
## By Role
### Main Characters
- [[01_protagonist|Protagonist]] - **Type:** Protagonist | **Status:** Active
- [[02_antagonist|Antagonist]] - **Type:** Antagonist | **Status:** Active
### Supporting Characters
*No supporting characters yet*
### Minor Characters
*No minor characters yet*
---
## By Status
### Active Characters
- [[01_protagonist|Protagonist]]
- [[02_antagonist|Antagonist]]
### Deceased Characters
*None yet*
### Retired Characters
*None yet*
---
## By Tag
### #protagonist
- [[01_protagonist|Protagonist]]
### #antagonist
- [[02_antagonist|Antagonist]]
### #main
- [[01_protagonist|Protagonist]]
- [[02_antagonist|Antagonist]]
---
## Quick Links
- [[Character Grid|character_grid]] - Quick reference table
- [[Add New Character|01_character_template]] - Template for new characters
## Statistics
- **Total Characters:** 2
- **Main:** 2
- **Supporting:** 0
- **Minor:** 0
@@ -0,0 +1,41 @@
---
title: Character Grid
type: grid
---
# Character Grid
*A quick-reference table summarizing all characters*
## Main Characters
| Name | Type | Age | Gender | Species | Role | Status | Motivation | First Appearance |
|------|------|-----|--------|---------|------|--------|------------|-----------------|
| [[01_protagonist|Protagonist]] | Protagonist | | | | | Active | | TBD |
| [[02_antagonist|Antagonist]] | Antagonist | | | | | Active | | TBD |
## Supporting Characters
| Name | Type | Age | Gender | Species | Role | Status | Motivation | First Appearance |
|------|------|-----|--------|---------|------|--------|------------|-----------------|
| Add characters here | | | | | | | | |
## Minor Characters
| Name | Type | Age | Gender | Species | Role | Status | Motivation | First Appearance |
|------|------|-----|--------|---------|------|--------|------------|-----------------|
| Add characters here | | | | | | | | |
---
## Quick Stats
- **Total Characters:** 2 main, 0 supporting, 0 minor
- **Last Updated:**
## Filter by Tag
- **#protagonist**
- **#antagonist**
- **#ally**
- **#enemy**
- **#mentor**
- **#romantic**
@@ -0,0 +1,166 @@
---
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
status: active
role: main
gender: ""
age: 0
species: ""
occupation: ""
tags: []
appearances: []
related_characters: []
related_locations: []
related_objects: []
secrets: []
created: ""
updated: ""
---
# {{ name }}
**{{ type | capitalizes }}** | **Status:** {{ status | capitalizes }} | **First Appears:** {{ appearances[0] if appearances else "TBD" }}
## Core Identity
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Pronunciation** | {{ pronunciation if pronunciation else "N/A" }} |
| **Alias/Nickname** | |
| **Age** | {{ age }} |
| **Gender** | {{ gender }} |
| **Species/Race** | {{ species }} |
| **Occupation** | {{ occupation }} |
| **Nationality** | |
| **Social Class** | |
## Physical Description
### Appearance
- **Height:**
- **Build:**
- **Hair:**
- **Eyes:**
- **Distinguishing Features:**
- **Style of Dress:**
### Voice & Mannerisms
- **Voice:**
- **Accent:**
- **Speech Patterns:**
- **Habits/Quirks:**
## Personality
### Strengths
-
-
-
### Flaws
-
-
-
### Likes
-
-
### Dislikes
-
-
### Fears
-
## Story Role
### Motivation
>
### Goal
>
### Conflict
>
### Character Arc
- **Beginning:**
- **Middle:**
- **End:**
## Relationships
### Family
-
### Allies
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
### Enemies
-
### Romantic
-
### Mentors/Students
-
## Backstory
### Early Life
>
### Defining Moment
>
### Before Story Begins
>
## Secrets & Hidden Knowledge
{% for secret in secrets %}- {{ secret }}
{% endfor %}
## Abilities & Skills
-
-
-
## Weaknesses
-
-
## Possessions
{% for object in related_objects %}- [[{{ object }}]]
{% endfor %}
-
## Key Dialogue
- **Single Quote:** ""
- **Memorable Line:** ""
## Character Notes
-
-
## Appearances in Story
{% for appearance in appearances %}- [[{{ appearance }}]]
{% endfor %}
## Inspiration
- **Real-world analogy:**
- **Actor reference:**
- **Archetype:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,163 @@
---
id: protagonist
name: Protagonist
summary: ""
slug: protagonist
pronunciation: ""
type: protagonist
status: active
role: main
gender: ""
age: 0
species: ""
occupation: ""
tags: [main, protagonist]
appearances: []
related_characters: []
related_locations: []
related_objects: []
secrets: []
created: ""
updated: ""
---
# Protagonist
**Protagonist** | **Status:** Active | **First Appears:** TBD
## Core Identity
| Attribute | Value |
|-----------|-------|
| **Full Name** | |
| **Pronunciation** | |
| **Alias/Nickname** | |
| **Age** | |
| **Gender** | |
| **Species/Race** | |
| **Occupation** | |
| **Nationality** | |
| **Social Class** | |
## Physical Description
### Appearance
- **Height:**
- **Build:**
- **Hair:**
- **Eyes:**
- **Distinguishing Features:**
- **Style of Dress:**
### Voice & Mannerisms
- **Voice:**
- **Accent:**
- **Speech Patterns:**
- **Habits/Quirks:**
## Personality
### Strengths
-
-
-
### Flaws
-
-
-
### Likes
-
-
### Dislikes
-
-
### Fears
-
## Story Role
### Motivation
>
### Goal
>
### Conflict
>
### Character Arc
- **Beginning:**
- **Middle:**
- **End:**
## Relationships
### Family
-
### Allies
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
### Enemies
-
### Romantic
-
### Mentors/Students
-
## Backstory
### Early Life
>
### Defining Moment
>
### Before Story Begins
>
## Secrets & Hidden Knowledge
{% for secret in secrets %}- {{ secret }}
{% endfor %}
## Abilities & Skills
-
-
-
## Weaknesses
-
-
## Possessions
{% for object in related_objects %}- [[{{ object }}]]
{% endfor %}
-
## Key Dialogue
- **Single Quote:** ""
- **Memorable Line:** ""
## Character Notes
-
-
## Appearances in Story
{% for appearance in appearances %}- [[{{ appearance }}]]
{% endfor %}
## Inspiration
- **Real-world analogy:**
- **Actor reference:**
- **Archetype:**
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,163 @@
---
id: antagonist
name: Antagonist
summary: ""
slug: antagonist
pronunciation: ""
type: antagonist
status: active
role: main
gender: ""
age: 0
species: ""
occupation: ""
tags: [main, antagonist]
appearances: []
related_characters: []
related_locations: []
related_objects: []
secrets: []
created: ""
updated: ""
---
# Antagonist
**Antagonist** | **Status:** Active | **First Appears:** TBD
## Core Identity
| Attribute | Value |
|-----------|-------|
| **Full Name** | |
| **Pronunciation** | |
| **Alias/Nickname** | |
| **Age** | |
| **Gender** | |
| **Species/Race** | |
| **Occupation** | |
| **Nationality** | |
| **Social Class** | |
## Physical Description
### Appearance
- **Height:**
- **Build:**
- **Hair:**
- **Eyes:**
- **Distinguishing Features:**
- **Style of Dress:**
### Voice & Mannerisms
- **Voice:**
- **Accent:**
- **Speech Patterns:**
- **Habits/Quirks:**
## Personality
### Strengths
-
-
-
### Flaws
-
-
-
### Likes
-
-
### Dislikes
-
-
### Fears
-
## Story Role
### Motivation
>
### Goal
>
### Conflict
>
### Character Arc
- **Beginning:**
- **Middle:**
- **End:**
## Relationships
### Family
-
### Allies
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
### Enemies
-
### Romantic
-
### Mentors/Students
-
## Backstory
### Early Life
>
### Defining Moment
>
### Before Story Begins
>
## Secrets & Hidden Knowledge
{% for secret in secrets %}- {{ secret }}
{% endfor %}
## Abilities & Skills
-
-
-
## Weaknesses
-
-
## Possessions
{% for object in related_objects %}- [[{{ object }}]]
{% endfor %}
-
## Key Dialogue
- **Single Quote:** ""
- **Memorable Line:** ""
## Character Notes
-
-
## Appearances in Story
{% for appearance in appearances %}- [[{{ appearance }}]]
{% endfor %}
## Inspiration
- **Real-world analogy:**
- **Actor reference:**
- **Archetype:**
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,45 @@
---
title: Locations Index
type: index
---
# Locations Index
*All locations in the story world, organized by type and region*
## By Type
### Regions
*No regions yet*
### Cities
*No cities yet*
### Buildings
*No buildings yet*
---
## By Region
*No regions defined yet*
---
## Quick Stats
- **Total Locations:** 0
- **Regions:** 0
- **Cities:** 0
- **Buildings:** 0
## Quick Links
- Add new [[Region|regions/01_region_template]]
- Add new [[City|cities/01_city_template]]
- Add new [[Building|buildings/01_building_template]]
## Tags
- **#capital**
- **#fortress**
- **#tavern**
- **#temple**
- **#ruins**
@@ -0,0 +1,146 @@
---
id: building_id
name: Building Name
slug: building-name
type: building
building_type: ""
status: active
location: ""
address: ""
built: ""
rebuilt: []
destroyed: ""
owner: ""
architect: ""
materials: []
size: ""
floors: 0
residents: []
staff: []
purpose: ""
accessibility: ""
secrets: []
related_characters: []
tags: []
created: ""
updated: ""
---
# {{ name }}
**{{ building_type | capitalizes if building_type else type | capitalizes }}** | **Status:** {{ status | capitalizes }} | **Location:** [[{{ location }}]]
## Core Information
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Type** | {{ building_type if building_type else type }} |
| **Location** | [[{{ location }}]] |
| **Address** | {{ address }} |
| **Built** | {{ built }} |
| **Rebuilt** | {{ rebuilt | join(", ") if rebuilt else "Never" }} |
| **Destroyed** | {{ destroyed if destroyed else "N/A" }} |
| **Owner** | {{ owner }} |
| **Architect** | {{ architect }} |
| **Materials** | {{ materials | join(", ") }} |
| **Size** | {{ size }} |
| **Floors** | {{ floors }} |
| **Purpose** | {{ purpose }} |
## Description
>
## Physical Details
### Exterior
- **Style:**
- **Color:**
- **Notable Features:**
- **Condition:**
### Interior
- **Layout:**
- **Decor:**
- **Lighting:**
- **Furnishings:**
### Rooms/Floors
| Floor | Room | Purpose | Notable Features |
|-------|------|---------|-------------------|
| | | | |
## History
### Construction
>
### Major Events
| Date | Event | Impact |
|------|-------|--------|
| | | |
### Current State
>
## Residents & Staff
### Current Residents
{% for resident in residents %}- [[{{ resident }}]]
{% endfor %}
### Current Staff
{% for person in staff %}- [[{{ person }}]]
{% endfor %}
### Former Residents
-
## Access & Security
### Entrances
- **Main:**
- **Service:**
- **Secret:**
### Security
- **Guards:**
- **Locks:**
- **Traps:**
- **Weaknesses:**
## Story Relevance
### Key Scenes
-
-
### Characters Associated
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
## Secrets & Hidden Knowledge
{% for secret in secrets %}- {{ secret }}
{% endfor %}
## Floor Plan (Optional)
```
+------------------+
| Entrance |
+------------------+
| Main Hall |
+--------+--------+
| Room A | Room B |
+--------+--------+
| Basement |
+------------------+
```
## Inspiration
- **Real-world analogy:**
- **Fictional inspiration:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,146 @@
---
id: city_id
name: City Name
slug: city-name
type: city
status: active
region: ""
population: 0
founded: ""
rebuilt: []
destroyed: ""
ruled_by: ""
government: ""
languages: []
currency: ""
religion: ""
industries: []
exports: []
imports: []
tags: []
related_characters: []
related_locations: []
created: ""
updated: ""
---
# {{ name }}
**City** | **Status:** {{ status | capitalizes }} | **Region:** {{ region }}
## Core Information
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Region** | {{ region }} |
| **Population** | {{ population }} |
| **Founded** | {{ founded }} |
| **Rebuilt** | {{ rebuilt | join(", ") if rebuilt else "Never" }} |
| **Destroyed** | {{ destroyed if destroyed else "N/A" }} |
| **Ruling Body** | {{ ruled_by }} |
| **Government** | {{ government }} |
| **Languages** | {{ languages | join(", ") }} |
| **Currency** | {{ currency }} |
| **Religion** | {{ religion }} |
## Description
>
## Geography
### Location
- **Coordinates:**
- **Elevation:**
- **Nearest Major City:**
- **Distance to Capital:**
### Districts/Neighborhoods
| Name | Purpose | Social Class | Notable Features |
|------|---------|--------------|-------------------|
| | | | |
### Landmarks
-
-
-
### Defenses
- **Walls:**
- **Gates:**
- **Guards:**
- **Weaknesses:**
## History
### Founding
>
### Major Events
| Date | Event | Impact |
|------|-------|--------|
| | | |
### Current State
>
## Culture & Society
### Demographics
- **Ethnic Groups:**
- **Social Classes:**
- **Notable Families:** {% for character in related_characters %}[[{{ character }}]]{% endfor %}
### Daily Life
- **Markets:**
- **Festivals:**
- **Traditions:**
### Economy
- **Primary Industries:** {{ industries | join(", ") }}
- **Exports:** {{ exports | join(", ") }}
- **Imports:** {{ imports | join(", ") }}
- **Wealth Level:**
### Government
- **Current Leader:**
- **Power Structure:**
- **Laws:**
- **Crime Level:**
## Political Relations
### Allied Cities
-
### Rival Cities
-
### Trade Partners
-
## Important Locations Within
{% for location in related_locations %}- [[{{ location }}]]
{% endfor %}
## Story Relevance
### Key Scenes
-
-
### Characters Associated
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
## Secrets & Hidden Knowledge
-
-
## Inspiration
- **Real-world analogy:**
- **Fictional inspiration:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,137 @@
---
id: region_id
name: Region Name
slug: region-name
type: region
status: active
parent: ""
contained_in: ""
contains: []
residents: []
related_locations: []
climate: ""
terrain: []
government: ""
population: 0
languages: []
curreny: ""
tags: []
created: ""
updated: ""
---
# {{ name }}
**Region** | **Status:** {{ status | capitalizes }} | **Type:** {{ type | capitalizes }}
## Core Information
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Type** | {{ type }} |
| **Parent Region** | {{ parent if parent else "None" }} |
| **Contained In** | {{ contained_in if contained_in else "None" }} |
| **Climate** | {{ climate }} |
| **Terrain** | {{ terrain | join(", ") }} |
| **Government** | {{ government }} |
| **Population** | {{ population }} |
| **Languages** | {{ languages | join(", ") }} |
| **Currency** | {{ currency }} |
## Description
>
## Geography
### Boundaries
- **North:**
- **South:**
- **East:**
- **West:**
### Major Features
-
-
-
### Climate & Weather
-
-
## History
### Founding
>
### Major Events
-
-
-
## Culture & Society
### People
- **Primary Residents:** {% for resident in residents %}[[{{ resident }}]]{% endfor %}
- **Ethnic Groups:**
- **Religions:**
- **Customs:**
### Economy
- **Primary Industries:**
- **Exports:**
- **Imports:**
- **Wealth Level:**
### Government
- **Ruling Body:**
- **Current Leader:**
- **Laws:**
- **Political Climate:**
## Political Relations
### Allies
-
### Enemies
-
### Neutral
-
## Locations Within
{% for location in contains %}- [[{{ location }}]]
{% endfor %}
## Related Locations
{% for location in related_locations %}- [[{{ location }}]]
{% endfor %}
## Travel Information
### From Capital
- **Distance:**
- **Time:**
- **Route:**
### Between Major Points
| From | To | Distance | Time | Notes |
|------|----|----------|------|-------|
| | | | | |
## Notable Events in Story
-
-
## Secrets & Hidden Knowledge
-
-
## Inspiration
- **Real-world analogy:**
- **Fictional inspiration:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,37 @@
---
title: Lore Index
type: index
---
# Lore Index
*World-building elements: history, factions, rules, and systems*
## Categories
### History
*No historical events yet*
### Factions
*No factions yet*
### World Rules
- [[World Rules|rules]] - Magic, technology, physics, and societal rules
---
## Quick Stats
- **Historical Events:** 0
- **Factions:** 0
- **Rules Documented:** 1
## Quick Links
- Add new [[Historical Event|history/01_history_template]]
- Add new [[Faction|factions/01_faction_template]]
## Tags
- **#magic**
- **#technology**
- **#war**
- **#religion**
- **#politics**
@@ -0,0 +1,197 @@
---
id: faction_id
name: Faction Name
slug: faction-name
type: faction
status: active
faction_type: ""
founded: ""
dissolved: ""
leader: ""
headquarters: ""
members: []
allies: []
enemies: []
neutral: []
goals: []
beliefs: []
resources: []
weaknesses: []
colors: []
symbols: []
motto: ""
tags: []
related_characters: []
related_locations: []
related_events: []
created: ""
updated: ""
---
# {{ name }}
**{{ faction_type | capitalizes if faction_type else "Faction" }}** | **Status:** {{ status | capitalizes }} | **Founded:** {{ founded }}
## Core Information
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Type** | {{ faction_type if faction_type else "Faction" }} |
| **Status** | {{ status | capitalizes }} |
| **Founded** | {{ founded }} |
| **Dissolved** | {{ dissolved if dissolved else "Active" }} |
| **Leader** | [[{{ leader }}]] |
| **Headquarters** | [[{{ headquarters }}]] |
| **Motto** | {{ motto if motto else "None" }} |
| **Colors** | {{ colors | join(", ") }} |
| **Symbols** | {{ symbols | join(", ") }} |
## Description
>
## Structure
### Hierarchy
```
{{ name }}
├── Leader: [[{{ leader }}]]
├── Second-in-Command:
├── Council:
│ ├──
│ └──
└── Members:
├──
└──
```
### Membership
| Rank | Title | Responsibilities | Current Holder |
|------|-------|-------------------|---------------|
| | | | |
### Recruitment
- **Requirements:**
- **Process:**
- **Oath:**
- **Ritual:**
## Goals & Objectives
### Primary Goals
{% for goal in goals %}- {{ goal }}
{% endfor %}
### Secondary Goals
-
-
### Ultimate Vision
>
## Beliefs & Ideology
### Core Beliefs
{% for belief in beliefs %}- {{ belief }}
{% endfor %}
### Philosophy
>
### Moral Code
-
-
## Resources
### Assets
{% for resource in resources %}- {{ resource }}
{% endfor %}
### Strengths
-
-
-
### Weaknesses
{% for weakness in weaknesses %}- {{ weakness }}
{% endfor %}
## Political Relations
### Allies
{% for ally in allies %}- [[{{ ally }}]]
{% endfor %}
### Enemies
{% for enemy in enemies %}- [[{{ enemy }}]]
{% endfor %}
### Neutral/Unaffiliated
{% for neutral in neutral %}- [[{{ neutral }}]]
{% endfor %}
### Rivals
-
## History
### Founding
>
### Major Events
| Date | Event | Impact |
|------|-------|--------|
| | | |
### Current Status
>
## Culture
### Traditions
-
-
### Rituals
-
-
### Taboos
-
-
## Story Relevance
### Key Scenes Involving Faction
-
-
### Characters Associated
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
### Locations Controlled
{% for location in related_locations %}- [[{{ location }}]]
{% endfor %}
### Events Involved In
{% for event in related_events %}- [[{{ event }}]]
{% endfor %}
## Secrets & Hidden Knowledge
-
-
## Public Perception
- **By Allies:**
- **By Enemies:**
- **By Neutrals:**
## Inspiration
- **Real-world parallel:**
- **Fictional inspiration:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,124 @@
---
id: history_event_id
name: Event Name
slug: event-name
type: event
era: ""
start_date: ""
end_date: ""
duration: ""
location: ""
participants: []
outcome: ""
impact: ""
related_events: []
related_characters: []
related_locations: []
tags: []
created: ""
updated: ""
---
# {{ name }}
**Historical Event** | **Era:** {{ era }} | **Date:** {{ start_date }} {{ "- " + end_date if end_date else "" }}
## Core Information
| Attribute | Value |
|-----------|-------|
| **Event Name** | {{ name }} |
| **Type** | {{ type }} |
| **Era** | {{ era }} |
| **Start Date** | {{ start_date }} |
| **End Date** | {{ end_date if end_date else "Ongoing" }} |
| **Duration** | {{ duration }} |
| **Location** | [[{{ location }}]] |
| **Outcome** | {{ outcome }} |
| **Impact Level** | {{ impact }} |
## Summary
>
## Participants
### Major Figures
{% for participant in participants %}- [[{{ participant }}]]
{% endfor %}
### Factions Involved
-
### Casualties
-
## Timeline
| Date | Event | Details |
|------|-------|---------|
| | | |
## Causes
-
-
-
## Event Details
### Pre-Event Situation
>
### Key Moments
1.
2.
3.
### Turning Points
-
-
### Aftermath
>
## Impact on Story World
### Political
-
### Social
-
### Economic
-
### Cultural
-
## Related Events
{% for event in related_events %}- [[{{ event }}]]
{% endfor %}
## Related Characters
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
## Related Locations
{% for location in related_locations %}- [[{{ location }}]]
{% endfor %}
## Secrets & Hidden Truths
-
-
## Historical Accounts
- **Official Record:**
- **Common Belief:**
- **Actual Truth:**
## Inspiration
- **Real-world parallel:**
- **Fictional inspiration:**
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,210 @@
---
id: rules
name: World Rules
slug: world-rules
type: rules
status: active
tags: [magic, technology, society, physics]
created: ""
updated: ""
---
# World Rules
*Fundamental rules governing the story world*
## Magic System
### Overview
>
### Source of Power
-
### Types of Magic
| Type | Description | Limitations | Practitioners |
|------|-------------|-------------|--------------|
| | | | |
### Rules & Limitations
#### Costs
-
-
#### Restrictions
-
-
#### Side Effects
-
-
### Learning & Mastery
- **How to Learn:**
- **Time Required:**
- **Innate vs. Taught:**
### Common Uses
-
-
-
### Forbidden Practices
-
-
---
## Technology Level
### Overview
>
### Common Technology
-
-
-
### Advanced Technology
-
-
-
### Lost/Forbidden Technology
-
-
### Technological Limitations
-
-
---
## Physical Laws
### Gravity & Physics
-
-
### Time
- **Flow:**
- **Time Travel:**
- **Time Manipulation:**
### Space & Cosmology
- **World Shape:**
- **Multiple Worlds:**
- **Astronomy:**
---
## Biological Rules
### Species & Races
-
-
### Lifespans
| Species | Average Lifespan | Maximum Lifespan |
|---------|-------------------|------------------|
| | | |
### Reproduction
-
-
### Diseases & Healing
-
-
---
## Societal Rules
### Laws & Justice
#### Universal Laws
-
-
#### Regional Variations
-
-
### Social Hierarchy
```
Top
├──
├──
└──
├──
└── Bottom
```
### Customs & Traditions
-
-
-
### Taboos
-
-
-
---
## Economic Rules
### Currency
- **Primary:**
- **Secondary:**
- **Exchange Rates:**
### Trade
- **Major Trade Routes:**
- **Common Goods:**
- **Rare Goods:**
### Wealth Distribution
-
-
---
## Divine & Spiritual Rules
### Gods & Deities
-
-
### Afterlife
-
-
### Religion
- **Major Religions:**
- **Minor Religions:**
- **Heretical Beliefs:**
---
## Rules by Location
| Location | Special Rules | Exceptions |
|----------|---------------|------------|
| | | |
---
## Rule Consistency Checklist
- [ ] Magic system rules followed in all scenes
- [ ] Technology level consistent across timeline
- [ ] Physical laws not violated
- [ ] Societal customs respected in character actions
- [ ] Economic rules applied consistently
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,34 @@
---
title: Objects Index
type: index
---
# Objects Index
*Important artifacts, weapons, and items in the story*
## Categories
### Artifacts
*No artifacts yet*
### Weapons
*No weapons yet*
---
## Quick Stats
- **Total Objects:** 0
- **Artifacts:** 0
- **Weapons:** 0
## Quick Links
- Add new [[Artifact|artifacts/01_artifact_template]]
- Add new [[Weapon|weapons/01_weapon_template]]
## Tags
- **#legendary**
- **#cursed**
- **#blessed**
- **#sentient**
- **#destroyed**
@@ -0,0 +1,172 @@
---
id: artifact_id
name: Artifact Name
slug: artifact-name
type: artifact
artifact_type: ""
status: active
origin: ""
created_by: ""
created_date: ""
discovered: ""
current_owner: ""
previous_owners: []
location: ""
materials: []
appearance: ""
power: ""
abilities: []
limitations: []
activation: ""
deactivation: ""
cost: ""
duration: ""
range: ""
target: ""
side_effects: []
danger_level: ""
secrets: []
related_characters: []
related_locations: []
related_events: []
related_lore: []
tags: []
created: ""
updated: ""
---
# {{ name }}
**{{ artifact_type | capitalizes if artifact_type else "Artifact" }}** | **Status:** {{ status | capitalizes }}
## Core Information
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Type** | {{ artifact_type if artifact_type else "Artifact" }} |
| **Origin** | {{ origin }} |
| **Created By** | {{ created_by }} |
| **Created Date** | {{ created_date }} |
| **Discovered** | {{ discovered }} |
| **Current Owner** | [[{{ current_owner }}]] |
| **Location** | [[{{ location }}]] |
| **Power Level** | {{ power }} |
| **Danger Level** | {{ danger_level }} |
## Description
### Physical Description
>
### Appearance
- **Size:**
- **Weight:**
- **Color:**
- **Material:** {{ materials | join(", ") }}
- **Shape:**
- **Markings:**
- **Condition:**
### Sensory Details
- **Sound:**
- **Smell:**
- **Texture:**
- **Temperature:**
- **Taste:**
## History
### Creation
>
### Ownership History
| Owner | Date Acquired | Date Lost | Method |
|-------|---------------|-----------|--------|
{% for owner in previous_owners %}| [[{{ owner }}]] | | | |
{% endfor %}
| [[{{ current_owner }}]] | | Current | |
### Significant Events
-
-
-
## Powers & Abilities
### Primary Power
>
### Abilities
{% for ability in abilities %}- **{{ ability }}**
{% endfor %}
### Limitations
{% for limitation in limitations %}- **{{ limitation }}**
{% endfor %}
### Activation
- **Method:** {{ activation }}
- **Requirements:**
- **Incantation:**
- **Ritual:**
### Deactivation
- **Method:** {{ deactivation }}
- **Conditions:**
- **Duration:** {{ duration }}
### Usage Rules
- **Cost:** {{ cost }}
- **Range:** {{ range }}
- **Target:** {{ target }}
- **Cooldown:**
### Side Effects
{% for effect in side_effects %}- {{ effect }}
{% endfor %}
## Weaknesses
-
-
-
## Secrets & Hidden Knowledge
{% for secret in secrets %}- {{ secret }}
{% endfor %}
## Story Relevance
### Key Scenes
-
-
### Characters Associated
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
### Locations Associated
{% for location in related_locations %}- [[{{ location }}]]
{% endfor %}
### Events Associated
{% for event in related_events %}- [[{{ event }}]]
{% endfor %}
### Lore Connections
{% for lore in related_lore %}- [[{{ lore }}]]
{% endfor %}
## Current Status
- **Location:**
- **Condition:**
- **Accessibility:**
- **Known By:**
## Inspiration
- **Real-world parallel:**
- **Fictional inspiration:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,167 @@
---
id: weapon_id
name: Weapon Name
slug: weapon-name
type: weapon
weapon_type: ""
status: active
origin: ""
created_by: ""
created_date: ""
current_owner: ""
previous_owners: []
location: ""
materials: []
appearance: ""
power: ""
abilities: []
limitations: []
damage: ""
range: ""
accuracy: ""
speed: ""
weight: ""
durability: ""
rarity: ""
secrets: []
related_characters: []
related_locations: []
related_events: []
tags: []
created: ""
updated: ""
---
# {{ name }}
**{{ weapon_type | capitalizes if weapon_type else "Weapon" }}** | **Status:** {{ status | capitalizes }}
## Core Information
| Attribute | Value |
|-----------|-------|
| **Full Name** | {{ name }} |
| **Type** | {{ weapon_type if weapon_type else "Weapon" }} |
| **Origin** | {{ origin }} |
| **Created By** | {{ created_by }} |
| **Created Date** | {{ created_date }} |
| **Current Owner** | [[{{ current_owner }}]] |
| **Location** | [[{{ location }}]] |
| **Rarity** | {{ rarity }} |
| **Power Level** | {{ power }} |
## Description
### Physical Description
>
### Appearance
- **Length:**
- **Weight:** {{ weight }}
- **Material:** {{ materials | join(", ") }}
- **Color:**
- **Shape:**
- **Markings:**
- **Condition:**
### Sensory Details
- **Sound:**
- **Smell:**
- **Texture:**
- **Temperature:**
## Combat Statistics
### Offensive
- **Damage:** {{ damage }}
- **Damage Type:**
- **Range:** {{ range }}
- **Accuracy:** {{ accuracy }}
- **Speed:** {{ speed }}
- **Critical Chance:**
- **Critical Effect:**
### Defensive
- **Block Chance:**
- **Parry Chance:**
- **Durability:** {{ durability }}
### Special Properties
{% for ability in abilities %}- **{{ ability }}**
{% endfor %}
### Limitations
{% for limitation in limitations %}- **{{ limitation }}**
{% endfor %}
## History
### Creation/Forge
>
### Ownership History
| Owner | Date Acquired | Date Lost | Method |
|-------|---------------|-----------|--------|
{% for owner in previous_owners %}| [[{{ owner }}]] | | | |
{% endfor %}
| [[{{ current_owner }}]] | | Current | |
### Notable Uses in History
-
-
-
### Significant Battles
-
-
## Usage
### Wielders
{% for character in related_characters %}- [[{{ character }}]]
{% endfor %}
### Combat Style
-
-
### Techniques
-
-
## Weaknesses
-
-
-
## Secrets & Hidden Knowledge
{% for secret in secrets %}- {{ secret }}
{% endfor %}
## Story Relevance
### Key Scenes
-
-
### Locations Associated
{% for location in related_locations %}- [[{{ location }}]]
{% endfor %}
### Events Associated
{% for event in related_events %}- [[{{ event }}]]
{% endfor %}
## Current Status
- **Location:**
- **Condition:**
- **Wielder:**
- **Known By:**
## Inspiration
- **Real-world parallel:**
- **Fictional inspiration:**
---
**Status:** {{ status | capitalizes }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,30 @@
---
title: Relationships Index
type: index
---
# Relationships Index
*Connections between characters, factions, and entities*
## Categories
### Character Relationships
- [[Character Relationships|character_relationships]] - All character connections and dynamics
### Faction Relationships
*No faction relationships yet*
---
## Quick Stats
- **Character Relationships:** 1 document
- **Faction Relationships:** 0
## Quick Links
- [[Characters Index|../01-Characters/_index]]
- [[Factions Index|../03-Lore/_index]]
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,130 @@
---
id: character_relationships
name: Character Relationships
slug: character-relationships
type: relationships
status: active
created: ""
updated: ""
---
# Character Relationships
*Network of relationships between characters*
## Relationship Types
- **Family:** Blood relations
- **Romantic:** Love interests, past and present
- **Friendship:** Close bonds
- **Rivalry:** Competitive relationships
- **Mentorship:** Teacher/student dynamics
- **Alliance:** Political or strategic partnerships
- **Enmity:** Active hostility
- **Betrayal:** Past or potential
---
## Relationship Web
```mermaid
graph LR
%% Add your character relationships here
%% Example format:
%% A[Character A] -->|Relationship Type| B[Character B]
%% Example:
% John -->|Brother| Jane
% John -->|Mentor| Robert
% John -->|Rival| Michael
% Jane -->|Romantic| James
%% Color coding:
%% classDef family fill:#f9f,stroke:#333;
%% classDef romantic fill:#ff9,stroke:#333;
%% classDef friendship fill:#9f9,stroke:#333;
%% classDef rivalry fill:#ff9,stroke:#333;
%% classDef enmity fill:#f99,stroke:#333;
```
---
## Detailed Relationships
### Main Character Relationships
| Character | Relationship | Target | Strength | Notes | Status |
|-----------|--------------|--------|----------|-------|--------|
| | | | | | |
### Family Trees
#### Family Group 1
```
Grandparent
├── Parent A
│ ├── Child 1
│ └── Child 2
└── Parent B
└── Child 3
```
#### Family Group 2
```
```
---
## Relationship Matrix
| From \ To | Character A | Character B | Character C | Character D |
|------------|-------------|-------------|-------------|-------------|
| **Character A** | - | | | |
| **Character B** | | - | | |
| **Character C** | | | - | |
| **Character D** | | | | - |
**Key:**
- **++** Strong positive
- **+** Positive
- **=** Neutral
- **- ** Negative
- **--** Strong negative
- **X** None/Unknown
---
## Relationship Tracking
### Active Relationships
-
-
### Changed Relationships
| Date | From | To | Change | Reason |
|------|------|----|--------|--------|
| | | | | |
### Foreshadowed Relationships
-
-
---
## Relationship Statistics
- **Total Connections:** 0
- **Family Bonds:** 0
- **Romantic Pairings:** 0
- **Mentorships:** 0
- **Rivalries:** 0
- **Enmities:** 0
---
## Quick Links
- [[Characters Index|../01-Characters/_index]]
- [[All Characters Grid|../01-Characters/character_grid]]
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,137 @@
---
id: timeline
name: Timeline
slug: timeline
type: timeline
status: active
current_era: ""
start_date: ""
end_date: ""
created: ""
updated: ""
---
# Timeline
*Chronological events in the story world*
## Eras Overview
| Era | Start | End | Duration | Description |
|-----|-------|-----|----------|-------------|
| | | | | |
---
## Detailed Timeline
### Pre-Story History
#### Ancient Era
| Date | Event | Location | Characters | Impact |
|------|-------|----------|-----------|--------|
| | | | | |
#### Classical Era
| Date | Event | Location | Characters | Impact |
|------|-------|----------|-----------|--------|
| | | | | |
#### Recent History
| Date | Event | Location | Characters | Impact |
|------|-------|----------|-----------|--------|
| | | | | |
---
### Story Timeline
#### Act 1: Setup
| Date | Chapter | Event | Location | Characters |
|------|---------|-------|----------|-----------|
| | | | | |
#### Act 2: Confrontation
| Date | Chapter | Event | Location | Characters |
|------|---------|-------|----------|-----------|
| | | | | |
#### Act 3: Resolution
| Date | Chapter | Event | Location | Characters |
|------|---------|-------|----------|-----------|
| | | | | |
---
### Post-Story Timeline
#### Immediate Aftermath
| Date | Event | Location | Characters | Impact |
|------|-------|----------|-----------|--------|
| | | | | |
#### Long-term Future
| Date | Event | Location | Characters | Impact |
|------|-------|----------|-----------|--------|
| | | | | |
---
## Timeline Visualization
```mermaid
gantt
title Story Timeline
dateFormat YYYY-MM-DD
section Ancient Era
Event 1 :a1, 0001-01-01, 100y
Event 2 :a2, after a1, 50y
section Classical Era
Event 3 :b1, 0101-01-01, 200y
section Story Events
Chapter 1 :c1, 1045-01-01, 1d
Chapter 2 :c2, after c1, 1d
Chapter 3 :c3, after c2, 1d
```
---
## Date Calculations
### Age Calculator
| Character | Birth Date | Current Story Date | Age |
|-----------|------------|--------------------|-----|
| | | | |
### Time Between Events
| From | To | Duration | Notes |
|------|----|----------|-------|
| | | | |
---
## Calendar System
### Months
| # | Name | Days | Season | Notes |
|---|------|------|--------|-------|
| 1 | | | | |
### Holidays & Festivals
| Date | Name | Type | Celebrated By |
|------|------|------|--------------|
| | | | |
### Weeks
- **Days in Week:**
- **Day Names:**
---
## Quick Links
- [[History Index|../03-Lore/_index]]
- [[Characters Index|../01-Characters/_index]]
---
**Current Era:** {{ current_era }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,140 @@
---
id: themes
name: Themes and Symbols
slug: themes
type: themes
status: active
created: ""
updated: ""
---
# Themes and Symbols
*Recurring ideas, motifs, and symbolic elements in the story*
---
## Major Themes
### Theme 1
> **Description:**
**Examples in Story:**
-
-
-
**Characters Embodying:**
-
-
**Key Scenes:**
-
-
---
### Theme 2
> **Description:**
**Examples in Story:**
-
-
-
**Characters Embodying:**
-
-
**Key Scenes:**
-
-
---
## Motifs
*Recurring elements or patterns*
| Motif | First Appearance | Recurrences | Symbolism |
|-------|------------------|-------------|-----------|
| | | | |
---
## Symbols
*Objects, colors, or elements with deeper meaning*
| Symbol | Represents | First Appearance | Key Moments |
|--------|------------|------------------|-------------|
| | | | |
---
## Symbolism by Color
| Color | Meaning | Associated With | Examples |
|-------|---------|----------------|----------|
| | | | |
---
## Symbolism by Object
| Object | Represents | Owner/Associated | Significance |
|--------|------------|------------------|--------------|
| | | | |
---
## Thematic Structure
### Act 1 Themes
-
-
### Act 2 Themes
-
-
### Act 3 Themes
-
-
---
## Character Themes
| Character | Primary Theme | Secondary Theme | Growth |
|-----------|---------------|-----------------|--------|
| | | | |
---
## Thematic Questions
*Questions the story explores:*
-
-
-
-
---
## Theme Tracking
### Theme Appearances by Chapter
| Chapter | Theme 1 | Theme 2 | Theme 3 |
|---------|---------|---------|---------|
| | | | |
---
## Quick Links
- [[Characters Index|../01-Characters/_index]]
- [[Plot Index|../../05-Plot]]
---
**Status:** Active | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,164 @@
---
id: consistency_checklist
name: Consistency Checklist
slug: consistency-checklist
type: tracking
status: active
last_checked: ""
checked_by: ""
created: ""
updated: ""
---
# Consistency Checklist
*Track and verify continuity across your story*
> **Check this file before finalizing each chapter.** Update as you write to catch inconsistencies early.
---
## Critical Checks (Must Verify)
### Character Consistency
- [ ] All character ages match the timeline
- [ ] Character names spelled consistently (including nicknames)
- [ ] Character appearances match descriptions
- [ ] Character motivations align with actions
- [ ] Character arcs progressing as planned
- [ ] Dialogue voices distinct and consistent
- [ ] Relationships updated after major events
- [ ] Secrets revealed in correct order
### Location Consistency
- [ ] Location descriptions match across mentions
- [ ] Distances between locations consistent
- [ ] Travel times calculated and consistent
- [ ] Location features (buildings, landmarks) correctly placed
- [ ] Weather/climate consistent with region and season
- [ ] Political control of locations updated
### Timeline Consistency
- [ ] Event dates don't conflict
- [ ] Character ages match birth dates
- [ ] Historical events align with character backstories
- [ ] Season and time of day consistent
- [ ] Day of week calculations correct
### World Rules Consistency
- [ ] Magic system rules followed in all scenes
- [ ] Technology level consistent
- [ ] Physical laws not violated
- [ ] Societal customs respected
- [ ] Economic rules applied consistently
---
## Chapter-by-Chapter Tracking
| Chapter | Characters | Locations | Timeline | Rules | Notes | Status |
|---------|------------|-----------|----------|--------|-------|--------|
| Ch 1 | | | | | |
| Ch 2 | | | | | |
| Ch 3 | | | | | |
---
## Character-Specific Tracking
### Protagonist
| Attribute | Value | First Mentioned | Last Verified | Status |
|-----------|-------|----------------|---------------|--------|
| Full Name | | | |
| Age | | | |
| Appearance | | | |
| Motivation | | | |
| Key Items | | | |
### Antagonist
| Attribute | Value | First Mentioned | Last Verified | Status |
|-----------|-------|----------------|---------------|--------|
| Full Name | | | |
| Age | | | |
| Appearance | | | |
| Motivation | | | |
| Key Items | | | |
---
## Location-Specific Tracking
| Location | Description | First Mentioned | Last Verified | Status |
|----------|-------------|----------------|---------------|--------|
| | | | |
---
## Magic/Technology Tracking
| System | Rule | Chapter | Compliance | Notes |
|--------|------|---------|------------|-------|
| | | | |
---
## Relationship Tracking
| Relationship | Status | Chapter Established | Last Mentioned | Notes |
|--------------|--------|---------------------|---------------|-------|
| | | | |
---
## Plot Hole Prevention
### Foreshadowing Check
- [ ] All Chekhov's guns fired
- [ ] All mysteries have clues
- [ ] All character skills used
- [ ] All location details relevant
### Continuity Check
- [ ] Injuries explained (healed or lingering)
- [ ] Items accounted for (gained/lost)
- [ ] Knowledge gained remembered
- [ ] Promises fulfilled or addressed
### Logic Check
- [ ] Character actions have motivation
- [ ] Event causes have setup
- [ ] Consequences follow actions
- [ ] Dialogue doesn't contradict facts
---
## Version History
| Date | Checked By | Findings | Resolved | Notes |
|------|------------|----------|----------|-------|
| | | | |
---
## Quick Verification Commands
Use your editor's search:
- `{{ '{{' }} character.name {{ '}}' }}` - Find all mentions of a character
- `{{ '{{' }} location.name {{ '}}' }}` - Find all mentions of a location
- `[[` - Find all cross-references
- `status:` - Find all status fields
---
## Final Check Before Publishing
- [ ] All consistency checks passed
- [ ] All timeline entries complete
- [ ] All character sheets updated
- [ ] All location descriptions final
- [ ] All relationship maps current
- [ ] All world rules followed
---
**Last Checked:** {{ last_checked if last_checked else "Never" }} | **By:** {{ checked_by if checked_by else "No one" }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,271 @@
# Story Bible
> **Your single source of truth for all story world information.**
The Story Bible is a comprehensive reference system that helps you maintain consistency, track connections, and ensure continuity across your entire narrative. Every fact about your story world should be documented here.
---
## Structure Overview
```
03-Story-Bible/
├── _index.md # Master index and navigation
├── README.md # This file
├── CONSISTENCY.md # Continuity tracking checklist
│
├── 01-Characters/ # All characters
│ ├── _index.md
│ ├── character_grid.md # Quick reference table
│ └── main_characters/ # Protagonist, antagonist, etc.
│ ├── 01_character_template.md
│ ├── 01_protagonist.md
│ └── 02_antagonist.md
│ └── supporting_characters/ # Secondary characters
│ └── minor_characters/ # Background characters
│
├── 02-Locations/ # World geography
│ ├── _index.md
│ ├── regions/
│ │ └── 01_region_template.md
│ ├── cities/
│ │ └── 01_city_template.md
│ └── buildings/
│ └── 01_building_template.md
│
├── 03-Lore/ # World-building
│ ├── _index.md
│ ├── history/
│ │ └── 01_history_template.md
│ ├── factions/
│ │ └── 01_faction_template.md
│ └── rules.md # Magic, tech, physics, society
│
├── 04-Objects/ # Important items
│ ├── _index.md
│ ├── artifacts/
│ │ └── 01_artifact_template.md
│ └── weapons/
│ └── 01_weapon_template.md
│
├── 05-Relationships/ # Connections
│ ├── _index.md
│ └── character_relationships.md
│
├── 06-Timeline/ # Chronology
│ └── _index.md
│
└── 07-Themes/ # Motifs and symbols
└── _index.md
```
---
## How to Use the Story Bible
### 1. Adding a New Character
1. **Copy the template:**
```bash
cp 01-Characters/main_characters/01_character_template.md 01-Characters/main_characters/03_new_character.md
```
2. **Fill in the YAML frontmatter first:**
```yaml
---
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
tags: [tag1, tag2]
appearances: [chapter_1, chapter_5]
related_characters: [character_a, character_b]
---
```
3. **Complete the character sheet** with all known information
4. **Add to index files:**
- Update `01-Characters/_index.md`
- Update `01-Characters/character_grid.md`
5. **Create cross-links** from other relevant entries
### 2. Adding a New Location
1. **Choose the type:** Region, City, or Building
2. **Copy the appropriate template**
3. **Fill in details** including relationships to characters
4. **Add to Locations index**
5. **Link from character files** that use this location
### 3. Adding Lore
- **Historical Events:** Copy template from `03-Lore/history/`
- **Factions:** Copy template from `03-Lore/factions/`
- **World Rules:** Edit `03-Lore/rules.md`
### 4. Maintaining Consistency
- **After each writing session:** Update relevant Bible entries
- **Before finalizing a chapter:** Run through `CONSISTENCY.md` checklist
- **Weekly review:** Spend 15 minutes updating and verifying entries
---
## Key Features
### YAML Frontmatter
Every entry starts with YAML metadata for easy filtering and searching:
```yaml
---
id: unique_identifier
name: Display Name
slug: url-friendly-name
type: category
tags: [tag1, tag2, tag3]
related_entities: [entity_a, entity_b]
status: active|draft|archived
created: "2024-01-01"
updated: "2024-01-01"
---
```
### Wiki-Links
Use `[[Link Text|file-name]]` or `[[file-name]]` to create cross-references:
```markdown
- **Current Owner:** [[John Doe]]
- **Location:** [[Castle Black]]
- **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:
````markdown
```mermaid
graph LR
A[John] -->|Brother| B[James]
A -->|Mentor| C[Robert]
```
````
### Tables
Use tables for structured data:
```markdown
| Attribute | Value |
|-----------|-------|
| Age | 30 |
| Gender | Male |
```
---
## Best Practices
### 1. Start Simple
- Begin with basic information
- Add details as they become relevant
- Don't over-document early drafts
### 2. Stay Current
- Update entries immediately after writing scenes
- Don't let the Bible fall behind
- Small updates are easier than big ones
### 3. Use Cross-Links
- Link characters to locations
- Link locations to events
- Link events to lore
- This creates a web of knowledge
### 4. Tag Everything
- Use consistent tags
- Filter by tags when searching
- Examples: `#protagonist`, `#capital`, `#magic`, `#war`
### 5. Review Regularly
- Weekly: Quick scan for obvious issues
- Monthly: Deep consistency check
- Before publishing: Full verification
---
## Search Tips
Use your editor's search feature:
| Search Term | Finds |
|-------------|-------|
| `[[` | All cross-references |
| `id:` | All YAML IDs |
| `status:` | All status fields |
| `tags:` | All tag lists |
| `#tagname` | All entries with that tag |
---
## Tag Reference
### Character Tags
`#protagonist` `#antagonist` `#ally` `#enemy` `#mentor` `#romantic` `#family` `#deceased` `#noble` `#commoner` `#warrior` `#mage` `#thief`
### Location Tags
`#capital` `#city` `#village` `#fortress` `#tavern` `#temple` `#ruins` `#wilderness` `#underground` `#floating` `#magical`
### Lore Tags
`#magic` `#technology` `#war` `#religion` `#politics` `#history` `#myth` `#legend` `#prophecy` `#culture`
### Object Tags
`#legendary` `#cursed` `#blessed` `#sentient` `#destroyed` `#lost` `#stolen` `#royal` `#sacred` `#ancient`
---
## Tools Integration
If your editor supports it:
- **Obsidian:** Native backlink support, graph view, tag search
- **VS Code:** Extensions for markdown preview and search
- **Typora:** Clean preview with YAML support
- **Any editor:** Basic search and replace works everywhere
---
## Example Workflow
1. **Write Chapter 5** where John visits Castle Black
2. **Update John's entry:** Add Castle Black to `related_locations`
3. **Update Castle Black entry:** Add John to `residents` or `visitors`
4. **Update Timeline:** Add "John visits Castle Black" event
5. **Check Consistency:** Verify travel time and castle description match
6. **Cross-link:** Ensure both entries link to each other
---
## Need Help?
- Check `_index.md` files for navigation
- Use `CONSISTENCY.md` for verification
- Look at existing entries for examples
- Refer to templates for required fields
---
**Remember:** The Story Bible is a living document. Update it as you write, and it will save you hours of continuity fixing later.
@@ -0,0 +1,148 @@
---
id: story_bible
name: Story Bible
slug: story-bible
type: master_index
status: active
world_name: ""
story_title: ""
author: ""
created: ""
updated: ""
---
# Story Bible
*Master index for all world-building, characters, locations, and lore*
> **The Story Bible is your source of truth.** Every fact about your story world should be documented here. Use it to maintain consistency, track connections, and ensure continuity across your narrative.
---
## Quick Navigation
| Section | Description | Count |
|---------|-------------|-------|
| [[Characters|01-Characters/_index]] | All characters in the story | 2 |
| [[Locations|02-Locations/_index]] | Places, regions, cities, buildings | 0 |
| [[Lore|03-Lore/_index]] | History, factions, world rules | 1 |
| [[Objects|04-Objects/_index]] | Artifacts, weapons, important items | 0 |
| [[Relationships|05-Relationships/_index]] | Character and faction connections | 1 |
| [[Timeline|06-Timeline/_index]] | Chronology of story events | 1 |
| [[Themes|07-Themes/_index]] | Recurring ideas and symbols | 1 |
---
## World Overview
| Attribute | Value |
|-----------|-------|
| **World Name** | {{ world_name if world_name else "Unnamed" }} |
| **Story Title** | {{ story_title if story_title else "Untitled" }} |
| **Author** | {{ author if author else "Unknown" }} |
| **Genre** | |
| **Setting** | |
| **Tone** | |
---
## Statistics Dashboard
### Characters
- **Main Characters:** 2
- **Supporting Characters:** 0
- **Minor Characters:** 0
- **Total:** 2
### Locations
- **Regions:** 0
- **Cities:** 0
- **Buildings:** 0
- **Total:** 0
### Lore
- **Historical Events:** 0
- **Factions:** 0
- **World Rules:** 1
### Objects
- **Artifacts:** 0
- **Weapons:** 0
- **Total:** 0
### Relationships
- **Character Relationships:** 1 document
- **Faction Relationships:** 0
---
## Recent Updates
| Date | Section | Change | By |
|------|---------|--------|----|
| | | | |
---
## Quick Start Guide
### Adding a New Character
1. Copy `01-Characters/main_characters/01_character_template.md`
2. Rename to `NN_name.md` (e.g., `03_elara.md`)
3. Fill in YAML frontmatter first
4. Add to appropriate index files
5. Link from character grid
### Adding a New Location
1. Choose type: Region, City, or Building
2. Copy corresponding template from `02-Locations/[type]/`
3. Rename and fill in details
4. Add to Locations index
5. Link from character/plot files
### Adding Lore
1. Historical events go in `03-Lore/history/`
2. Factions go in `03-Lore/factions/`
3. World rules go in `03-Lore/rules.md`
4. Update Lore index
---
## Consistency Checklist
- [ ] All character ages match timeline
- [ ] Location descriptions consistent across mentions
- [ ] Magic/technology rules followed in all scenes
- [ ] Character relationships updated after major events
- [ ] All named entities have Bible entries
- [ ] Cross-links between entries are accurate
---
## Search Tips
Use your editor's search to find:
- `[[` - All wiki-links (cross-references)
- `id:` - All YAML IDs
- `status:` - All status fields
- `tags:` - All tags for filtering
---
## Tags Reference
### Character Tags
`#protagonist` `#antagonist` `#ally` `#enemy` `#mentor` `#romantic` `#family` `#deceased`
### Location Tags
`#capital` `#city` `#village` `#fortress` `#tavern` `#temple` `#ruins` `#wilderness`
### Lore Tags
`#magic` `#technology` `#war` `#religion` `#politics` `#history` `#myth`
### Object Tags
`#legendary` `#cursed` `#blessed` `#sentient` `#destroyed` `#lost` `#stolen`
---
**World:** {{ world_name if world_name else "Unnamed" }} | **Created:** {{ created }} | **Last Updated:** {{ updated }}
@@ -0,0 +1,13 @@
---
title: "Scene 1"
slug: "scene-1"
act: 1
chapter: 1
scene: 1
type: scene
status: draft
word_count_target: 2000
pov: ""
---
[Write your scene content here...]
@@ -0,0 +1,13 @@
---
title: "Scene 2"
slug: "scene-2"
act: 2
chapter: 2
scene: 2
type: scene
status: draft
word_count_target: 2000
pov: ""
---
[Write your scene content here...]
@@ -0,0 +1,13 @@
---
title: "Scene 3"
slug: "scene-3"
act: 3
chapter: 3
scene: 3
type: scene
status: draft
word_count_target: 2000
pov: ""
---
[Write your scene content here...]
@@ -0,0 +1,11 @@
---
title: "Unsorted Scene"
slug: ""
status: draft
pov: ""
characters: []
setting: ""
word_count_target: 1800-2200
---
[Write your scene content here...]
@@ -0,0 +1,22 @@
---
# 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
status: draft
pov: ""
characters: []
setting: ""
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count_target: 1800-2200
---
[Write your scene content here...]
@@ -1,23 +0,0 @@
# Title
# Slug
## POV:
## Characters:
## Setting:
## Conflict:
## Feeling to convey:
## Word Count Target:
## The Setup:
## The Reveal:
### Things to Note:
### Rough draft: