diff --git a/README.md b/README.md index e966627..b61be5f 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,50 @@ 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. +## Project mode + +Open a **project root** — a folder holding the manuscript alongside the +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 +plain folder of chapters has no such subfolder, so it behaves exactly as before +— everything in it is the manuscript. + +In a project: + +* **Files under the manuscript folder are the book.** They are ordered, + numbered, and they are what **Export ODT** concatenates. Chapter numbering + counts only them, so a defaulted title is `3.` because it is the third + *chapter*, not the third file in the 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)), + 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, + with the manuscript folder tagged **· manuscript**. +* `order.json`, `titles.json` and `wordlist.json` live at the **project root**, + so one word list covers the whole project rather than one folder of it. +* **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 +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 + +An archive of superseded drafts can hold hundreds of files that would swamp the +tree, so folders named like **`Archive`** are skipped by the scan entirely — the +same treatment `target/` and `node_modules/` already get. The match ignores a +leading number, so `10-Archive` is covered. + +Change the list with `hidden_folders` in +`~/.config/md-manuscript/config.json`; set it to `[]` to see everything. + ## Organising with folders The file list is a tree. Any `*.md` file at or below the workspace is part of the @@ -182,6 +226,101 @@ bottom**, so the order you see down the left is the order the chapters are concatenated in, folders and all. Chapter numbering follows the same sequence, so a defaulted title in `part-2/` continues counting from `part-1/`. +## Working on the novel's other parts + +A manuscript project is more than its chapters. These read the rest of it. + +### 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. + +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 +that way rather than merely scrolling past the header means search, replace, +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. + +### Characters (Tools ▸ Characters…) + +Reads the project's character sheets — a file whose level-1 heading is the name, +as either `# Bixby Reynolds` or `# Name: Jonathan Bronski`. For each character +it shows: + +* their descriptor, from a `Slug:` field or the heading beneath the name; +* **which scenes they appear in**, by matching the `Characters:` line of every + manuscript file. A first name matches a full name, so `Characters: Bixby, Mom` + finds *Bixby Reynolds*. Click a scene to open it; +* **▦ Rebuild grid**, which regenerates the project's `character_grid.md` from + the sheets as a markdown table. A hand-kept grid drifts the moment a sheet + changes; this one cannot. Long values are trimmed, since a grid is a + quick-look summary. + +### Outline (Tools ▸ Outline…) + +The snowflake outline files are scaffolds of HTML comments, one per beat +(``). The app strips comments, so +these files otherwise render and export as *blank* — there is no way to see how +much of the outline exists. + +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. + +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. + +### Revision status + +Give a file a `Status:` header line and the file panel shows a compact badge for +it (initials, so `Structural Edit` becomes `SE`) and offers a **Status** filter +above the list. The filter narrows the tree to one stage — every file you have +still to proofread, say. + +### Archive + +**🗄 Archive** moves the selected file into the project's archive folder, +keeping its place inside it (`Act 1/scene.md` → `10-Archive/Act 1/scene.md`). +It leaves the manuscript order and, since archive folders are +[hidden](#hidden-folders), the file panel — but stays on disk. Deleting is for +things you want gone; this is for things you have moved past. + +### Changes since the last commit (Edit ▸ Changes…) + +Diffs the open file against its committed version, coloured the way git colours +a diff. Revising is comparing, and the repository already holds the answer. + +### Word counts + +The status bar carries the open file's word count, its change this session, and +the **whole manuscript's total**. Reference files are not counted. + +### Manuscript details (Settings ▸ Manuscript details…) + +Title and author, written into exported `.odt` files as their document +properties — what a word processor shows under File ▸ Properties. A blank title +uses the project folder's name. Exports previously carried no metadata at all. + +### Exporting chapter files (File ▸ Export chapters + master) + +Writes one `.odt` per chapter into a `chapters/` folder, plus an `.odm` master +document that links them — the shape the template's `Full Text.odm` implies. + +**The per-chapter files are the reliable part**: they are ordinary documents and +open anywhere. The master is a shell with a title page and one linked section +per chapter; LibreOffice does not follow those links when it opens the file, so +treat it as a starting point to relink rather than as the assembled book. For a +single finished document, use **Export ODT**. + ## New projects from a cookiecutter template **File ▸ ✨ New project…** scaffolds a whole manuscript project from a @@ -300,6 +439,37 @@ results take over the underlines until you next edit the text — at which point the live offline checker resumes. In other words, LanguageTool is used when it's available and current; the offline checker is the always-on default. +### The word list (names and invented terms) + +No Hunspell dictionary knows your characters, so without somewhere to record +them a novel's cast is underlined on every line of every scene. **Settings ▸ +Word list…** is that place. + +* **Right-click an underlined word** in the editor and choose + **📗 Add “…” to the word list**. The underline goes immediately — there is no + wait for the next check. +* **👤 Add character names** reads the project's character sheets and lists + 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` + 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*. +* Words can also be **typed in** directly, and taken back out with **✖**. + +Matching is case-insensitive, and a listed name covers its **possessive and +plural** — `Bixby` accepts `Bixby's`, `Bixbys` and `Bixbys'` — because the +tokenizer hands the checker `Bixby's` as a single word. + +The list is saved as **`wordlist.json`** in the workspace, beside `order.json` +and `titles.json`, so it is committed to git and travels with the manuscript. It +is a plain sorted JSON array, and a corrupt or hand-mangled file is treated as +empty rather than stopping the checker. + +Note that the list belongs to the *workspace*, not the whole project — open a +different folder of the same project and it has its own list. + The bundled dictionaries live under `dictionaries/` and are derived from [SCOWL](http://wordlist.sourceforge.net/) under a permissive license (kept alongside them in each `license` file). @@ -467,8 +637,12 @@ only triggers in the header region, never in the prose below the marker. | `/**/*.md` | your manuscript files, in folders if you like | | `/order.json` | the manual ordering, as paths (committed to git) | | `/titles.json` | chapter-title overrides, keyed by path (committed to git) | +| `/wordlist.json` | names the spell checker accepts (committed to git) | | `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template, project/Gitea settings | +In [project mode](#project-mode) the workspace is the project root, so those +three bookkeeping files sit there and cover the whole project. + ## Markdown supported in ODT export Headings, paragraphs, **bold**, *italic*, ***both***, `inline code`, fenced/indented diff --git a/src/app/autocomplete.rs b/src/app/autocomplete.rs index 86af9eb..237d33a 100644 --- a/src/app/autocomplete.rs +++ b/src/app/autocomplete.rs @@ -26,7 +26,7 @@ impl App { /// Fold any new header field names from the current buffer into the list, so /// a field you just invented autocompletes without reopening the workspace. pub(super) fn merge_field_names_from_buffer(&mut self) { - let found = header_field_names(&self.buffer, &self.config.draft_marker); + let found = header_field_names(&self.document(), &self.config.draft_marker); let mut added = false; for f in found { if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) { diff --git a/src/app/beats.rs b/src/app/beats.rs index ccd3f78..4aa79aa 100644 --- a/src/app/beats.rs +++ b/src/app/beats.rs @@ -144,6 +144,9 @@ impl App { let mut open = true; let mut close = false; let mut save = false; + let mut append_to_outline = false; + // Where these beats belong in the project's outline, if it has one. + let outline_target = self.outline_target_for_beats(); let running = self.beats_rx.is_some(); // Edit a local copy so the button row can borrow `self` mutably; persist // any edits back into `beats_output` afterwards. @@ -182,6 +185,18 @@ impl App { { save = true; } + if let Some(target) = &outline_target { + let label = format!("📝 Append to {}", base_name(target)); + if ui + .add_enabled(have, egui::Button::new(label)) + .on_hover_text(format!( + "Add these beats to {target}, where the outline lives" + )) + .clicked() + { + append_to_outline = true; + } + } if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() { ui.output_mut(|o| o.copied_text = text.clone()); self.beats_status = "Copied to clipboard.".to_string(); @@ -198,6 +213,11 @@ impl App { } if save { self.save_beats_as_file(&text); + } + if append_to_outline { + if let Some(target) = outline_target { + self.append_beats_to_outline(&target, &text); + } } else if close || !open { self.beats_output = None; self.beats_status.clear(); @@ -279,6 +299,64 @@ impl App { } self.show_mistral_settings = now_open; } + + /// The outline file these beats belong in, as a workspace-relative path. + /// + /// A fill-the-gaps run wrote exactly one act, and the scene-breakdown + /// scaffold has a file per act, so the two line up: `Act Two` goes to the + /// file whose name carries a 2. Anything less clear-cut gets no target, + /// since appending to the wrong act would be worse than not offering it. + pub(super) fn outline_target_for_beats(&self) -> Option { + let [act] = self.beats_filled.as_slice() else { + return None; + }; + let digit = match act { + crate::mistral::Act::One => '1', + crate::mistral::Act::Two => '2', + crate::mistral::Act::Three => '3', + }; + let dir = self.outline_dir()?; + markdown_files_under(&dir) + .into_iter() + .filter(|path| { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase(); + stem.contains("act") && stem.contains(digit) + }) + .find_map(|path| self.relative_to_workspace(&path)) + } + + /// Append generated beats to an outline file, under a heading that says + /// where they came from. Appending rather than replacing matters: the + /// scaffold's prompts are the reason the file is worth keeping. + pub(super) fn append_beats_to_outline(&mut self, rel: &str, beats: &str) { + let path = self.path_for(rel); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut out = existing; + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(&format!( + "\n\n\n{}\n", + today_utc(), + beats.trim() + )); + match std::fs::write(&path, out) { + Ok(_) => { + self.beats_status = format!("Appended to {rel}"); + self.beats_output = None; + // Open it so the result is in front of the user. + if let Some(idx) = self.files.iter().position(|f| f == rel) { + self.selected = None; + self.select(idx); + } + } + Err(e) => self.beats_status = format!("Could not write {rel}: {e}"), + } + } } /// Join act names for a status line or title: "Act Two", "Act Two and Act diff --git a/src/app/characters.rs b/src/app/characters.rs new file mode 100644 index 0000000..d3f9b4b --- /dev/null +++ b/src/app/characters.rs @@ -0,0 +1,200 @@ +//! The Characters window: the project's cast, where each of them appears, and +//! keeping the summary grid in step with the sheets. + +use super::*; + +impl App { + /// (Re)read the project's character sheets. + pub(super) fn load_characters(&mut self) { + self.characters = match self.characters_dir() { + Some(dir) => crate::characters::read_sheets(&dir, &self.config.draft_marker), + None => Vec::new(), + }; + } + + /// Manuscript files whose `Characters:` line names `character`, as + /// workspace-relative paths. + /// + /// This is the continuity question the app can answer that a folder of + /// markdown cannot: once a draft is long, "which scenes is she actually in?" + /// stops being answerable from memory. + pub(super) fn scenes_with(&self, character: &crate::characters::Character) -> Vec { + self.files + .iter() + .filter(|name| self.is_manuscript(name)) + .filter(|name| { + let Some(text) = std::fs::read_to_string(self.path_for(name)).ok() else { + return false; + }; + let Some(line) = + crate::preprocess::field(&text, &self.config.draft_marker, "Characters") + else { + return false; + }; + crate::preprocess::value_items(&line) + .iter() + .any(|mention| character.is_named_by(mention)) + }) + .cloned() + .collect() + } + + /// Write the generated character grid over the project's grid file. + fn regenerate_character_grid(&mut self) { + let Some(dir) = self.characters_dir() else { + self.status = "No character folder found near this workspace".to_string(); + return; + }; + let markdown = crate::characters::grid_markdown(&self.characters); + // Reuse the grid file the project already has, wherever it sits. + let existing = crate::characters::grid_file(&dir); + let path = existing.unwrap_or_else(|| dir.join("character_grid").join("character_grid.md")); + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + self.status = format!("Could not write the grid: {e}"); + return; + } + } + match std::fs::write(&path, markdown) { + Ok(_) => { + self.status = format!( + "Wrote {} character(s) to {}", + self.characters.len(), + path.display() + ); + // The grid may be one of the workspace's own files. + self.file_meta = self.snapshot_file_meta(); + } + Err(e) => self.status = format!("Could not write the grid: {e}"), + } + } + + /// The Characters window. + pub(super) fn characters_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_characters; + let mut close = false; + let mut regenerate = false; + let mut reload = false; + // A scene or sheet the user asked to open, as a workspace-relative path. + let mut open_file: Option = None; + + egui::Window::new("Characters") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(420.0) + .show(ctx, |ui| { + match self.characters_dir() { + Some(dir) => { + ui.label( + egui::RichText::new(format!("Sheets in {}", dir.display())) + .small() + .weak(), + ); + } + None => { + ui.label( + egui::RichText::new( + "No character folder found near this workspace.", + ) + .weak(), + ); + } + } + ui.horizontal(|ui| { + if ui.button("⟳ Reload sheets").clicked() { + reload = true; + } + if ui + .add_enabled( + !self.characters.is_empty(), + egui::Button::new("▦ Rebuild grid"), + ) + .on_hover_text( + "Regenerate the character grid from the sheets, replacing \ + whatever is in it", + ) + .clicked() + { + regenerate = true; + } + }); + ui.separator(); + + if self.characters.is_empty() { + ui.label(egui::RichText::new("No character sheets read yet.").weak()); + } + + egui::ScrollArea::vertical() + .auto_shrink([false, true]) + .max_height(420.0) + .show(ui, |ui| { + for character in &self.characters { + let scenes = self.scenes_with(character); + let header = format!( + "{} ({} scene{})", + character.name, + scenes.len(), + if scenes.len() == 1 { "" } else { "s" } + ); + egui::CollapsingHeader::new(header) + .id_salt(&character.name) + .show(ui, |ui| { + if let Some(tagline) = character.tagline() { + ui.label(egui::RichText::new(tagline).italics().weak()); + } + if ui.link("open sheet").clicked() { + if let Some(rel) = self.relative_to_workspace( + &character.file, + ) { + open_file = Some(rel); + } + } + if scenes.is_empty() { + ui.label( + egui::RichText::new( + "Not named in any scene's Characters: line.", + ) + .small() + .weak(), + ); + } + for scene in &scenes { + if ui.link(scene).clicked() { + open_file = Some(scene.clone()); + } + } + }); + } + }); + + ui.separator(); + if ui.button("Close").clicked() { + close = true; + } + }); + + if reload { + self.load_characters(); + } + if regenerate { + self.regenerate_character_grid(); + } + if let Some(path) = open_file { + if let Some(idx) = self.files.iter().position(|f| *f == path) { + self.select(idx); + } else { + self.status = format!("{path} is not in this workspace"); + } + } + self.show_characters = open && !close; + } + + /// Express an absolute path as workspace-relative, if it is inside. + pub(super) fn relative_to_workspace(&self, path: &Path) -> Option { + path.strip_prefix(self.workspace()) + .ok()? + .to_str() + .map(|s| s.replace('\\', "/")) + } +} diff --git a/src/app/diff.rs b/src/app/diff.rs new file mode 100644 index 0000000..317cdc1 --- /dev/null +++ b/src/app/diff.rs @@ -0,0 +1,99 @@ +//! Comparing the open file with its last committed version. + +use super::*; + +impl App { + /// Load the diff for the current file, ready for the diff window. + pub(super) fn open_diff(&mut self) { + 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; + }; + // 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 { + self.diff_text = "This workspace is not in a git repository.".to_string(); + self.show_diff = true; + return; + }; + if !gitsync::has_commits(&root) { + self.diff_text = "The repository has no commits yet, so there is \ + nothing to compare against." + .to_string(); + self.show_diff = true; + return; + } + let abs = self.path_for(&name); + let rel = abs + .strip_prefix(&root) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| name.clone()); + self.diff_text = match gitsync::diff_file(&root, &rel) { + Ok(text) if text.trim().is_empty() => { + format!("{name} matches the last commit — no changes.") + } + Ok(text) => text, + Err(e) => format!("Could not diff {name}:\n{e}"), + }; + self.diff_title = name; + self.show_diff = true; + } + + /// The diff window: added and removed lines since the last commit. + pub(super) fn diff_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_diff; + let mut refresh = false; + egui::Window::new(format!("Changes — {}", self.diff_title)) + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(680.0) + .default_height(460.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("⟳ Refresh").clicked() { + refresh = true; + } + ui.label( + egui::RichText::new("compared with the last commit") + .small() + .weak(), + ); + }); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for line in self.diff_text.lines() { + // Colour the diff the way git does, so additions and + // removals are separable at a glance. + let (color, strong) = match line.as_bytes().first() { + Some(b'+') if !line.starts_with("+++") => { + (Some(egui::Color32::from_rgb(0x3F, 0x9E, 0x4F)), false) + } + Some(b'-') if !line.starts_with("---") => { + (Some(egui::Color32::from_rgb(0xC0, 0x50, 0x50)), false) + } + Some(b'@') => { + (Some(egui::Color32::from_rgb(0x3B, 0x82, 0xF6)), true) + } + _ => (None, false), + }; + let mut text = egui::RichText::new(line).monospace(); + if let Some(color) = color { + text = text.color(color); + } + if strong { + text = text.strong(); + } + ui.label(text); + } + }); + }); + if refresh { + self.open_diff(); + } + self.show_diff = open; + } +} diff --git a/src/app/editor.rs b/src/app/editor.rs index ca3d413..c8ba678 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -110,7 +110,7 @@ impl App { self.buffer = new_text; self.dirty = true; // Restore the caret/selection around the change. - let mut state = output.state; + let mut state = output.state.clone(); state.cursor.set_char_range(Some(egui::text::CCursorRange::two( egui::text::CCursor::new(new_sel.start), egui::text::CCursor::new(new_sel.end), @@ -120,6 +120,32 @@ impl App { } } + // 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() { + let (cs, ce) = ( + byte_to_char(&self.buffer, start), + byte_to_char(&self.buffer, 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()); + ui.scroll_to_rect(rect, Some(egui::Align::Center)); + let mut state = output.state.clone(); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(cs), + egui::text::CCursor::new(ce), + ))); + state.store(ui.ctx(), output.response.id); + ui.ctx().memory_mut(|m| m.request_focus(output.response.id)); + ui.ctx().request_repaint(); + } + // Scroll the active search match into view when requested (on // open, Next/Prev, Enter, or after a replace). if self.find_scroll { @@ -162,6 +188,15 @@ impl App { Some((i, reps)) }); let mut chosen: Option<(usize, usize)> = None; + // The word under the menu, so it can be added to the word list. + let target_word: Option = self.spell_menu.and_then(|i| { + let m = self.current_matches().get(i)?; + m.spelling + .then(|| self.buffer.get(m.start..m.end)) + .flatten() + .map(str::to_string) + }); + let mut accept: Option = None; output.response.context_menu(|ui| { match &menu { Some((i, reps)) if !reps.is_empty() => { @@ -180,7 +215,25 @@ impl App { ui.label(egui::RichText::new("No spelling issue here").weak()); } } + // A character or place name is not a misspelling; let it be + // accepted for good rather than corrected every time. + if let Some(word) = &target_word { + ui.separator(); + if ui + .button(format!("📗 Add “{word}” to the word list")) + .on_hover_text( + "Accept this word from now on, for this workspace", + ) + .clicked() + { + accept = Some(word.clone()); + ui.close_menu(); + } + } }); + if let Some(word) = accept { + self.add_to_dictionary(&word); + } if let Some((i, j)) = chosen { self.apply_current_fix(i, j); self.spell_menu = None; @@ -401,6 +454,14 @@ pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize { .unwrap_or(s.len()) } +/// Character index of byte offset `byte` in `s`, clamped to the end. An offset +/// landing inside a character — a stale match against an edited buffer — is +/// clamped to the next character boundary, matching how [`crate::langtool`] +/// resolves its UTF-16 offsets, so a jump never lands mid-character. +pub(super) fn byte_to_char(s: &str, byte: usize) -> usize { + s.char_indices().take_while(|(b, _)| *b < byte).count() +} + /// Apply an inline-format action to `text` given a selection in *character* /// indices, returning the new text and the new selection (also character indices). /// @@ -470,6 +531,28 @@ pub(super) fn apply_format( mod tests { use super::*; + #[test] + fn byte_to_char_is_the_inverse_of_char_to_byte() { + // "café!" — é is 2 bytes, so byte and char indices diverge after it. + let text = "café!"; + for (i, _) in text.char_indices() { + assert_eq!(char_to_byte(text, byte_to_char(text, i)), i); + } + assert_eq!(byte_to_char(text, 0), 0); + assert_eq!(byte_to_char(text, 3), 3); // é + assert_eq!(byte_to_char(text, 5), 4); // '!' sits after the 2-byte é + } + + #[test] + fn byte_to_char_clamps_past_the_end_and_inside_a_character() { + let text = "a😀b"; + assert_eq!(byte_to_char(text, text.len()), 3); + assert_eq!(byte_to_char(text, 99), 3); + // Byte 3 is inside the 4-byte emoji, so it clamps forward to the + // boundary after it rather than splitting the character. + assert_eq!(byte_to_char(text, 3), 2); + } + #[test] fn wraps_a_selection_in_bold_and_keeps_it_selected() { let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold); diff --git a/src/app/file_list.rs b/src/app/file_list.rs index a0c793f..ee8ba54 100644 --- a/src/app/file_list.rs +++ b/src/app/file_list.rs @@ -21,8 +21,9 @@ pub(super) enum RowKind { /// A folder header: how many markdown files live under it at any depth, and /// the flat index of the first of them (where a drop into the folder lands). Folder { count: usize, first: usize }, - /// A file, by its index into [`App::files`]. - File { idx: usize }, + /// A file. The panel resolves its own index against `App::files`, because a + /// status filter means the row list and the file list no longer line up. + File, } /// Where a dragged file was let go: which file moved, the flat position it @@ -96,7 +97,7 @@ pub(super) fn build_rows(files: &[String], collapsed: &HashSet) -> Vec = None; let mut toggled: Option = None; let mut dropped: Option = None; let pointer = ui.input(|i| i.pointer.interact_pos()); - let rows = build_rows(&self.files, &self.collapsed); + // Filtering happens on the flat list, so folders left with no + // files simply stop appearing. + let show_reference = self.config.show_reference_files; + let visible: Vec = self + .files + .iter() + .filter(|name| show_reference || self.is_manuscript(name)) + .filter(|name| match &self.status_filter { + None => true, + Some(want) => self + .file_meta + .get(*name) + .and_then(|m| m.status.as_deref()) + .is_some_and(|s| s.eq_ignore_ascii_case(want)), + }) + .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); egui::ScrollArea::vertical() @@ -138,6 +201,12 @@ impl App { match row.kind { RowKind::Folder { count, first } => { let open = !self.collapsed.contains(&row.path); + // In a project, one folder holds the book. + let is_manuscript_root = self + .manuscript_dir + .as_deref() + .is_some_and(|d| d == row.path); + let in_book = self.is_manuscript(&row.path); let header = ui .horizontal(|ui| { ui.add_space(indent); @@ -146,12 +215,26 @@ impl App { "{arrow} 🗀 {}", base_name(&row.path) ); + let text = egui::RichText::new(label); + let text = if in_book { + text.strong() + } 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. + let name_w = + (row_width - indent - COUNT_W).max(48.0); if ui - .add( - egui::Button::new( - egui::RichText::new(label).strong(), - ) - .frame(false), + .add_sized( + [name_w, 20.0], + egui::Button::new(text) + .frame(false) + .truncate(), ) .on_hover_text(&row.path) .clicked() @@ -163,35 +246,59 @@ impl App { .small() .weak(), ); - // Claim the rest of the line so the - // whole row is a drop target. - ui.allocate_space(egui::vec2( - ui.available_width(), - 0.0, - )); + if is_manuscript_root { + ui.label( + egui::RichText::new("· manuscript") + .small() + .weak(), + ) + .on_hover_text( + "These files are the book: ordered, \ + numbered and exported. Everything \ + else in the project is reference.", + ); + } }) .response; + // Widened only for hit-testing and painting, + // which cannot affect the layout's width. + let header = full_width_row(ui, &header, &row.path); if drop_highlight(ui, &header) { if let Some(payload) = header.dnd_release_payload::() { + // `first` indexes the filtered list. + let to = visible + .get(first) + .and_then(|n| { + self.files.iter().position(|f| f == n) + }) + .unwrap_or(self.files.len()); dropped = Some(FileDrop { from: *payload, - to: first, + to, dir: row.path.clone(), }); } } } - RowKind::File { idx } => { + RowKind::File => { let name = row.path.clone(); + // `build_rows` indexed the filtered list; the + // rest of the app speaks in `files` indices. + let Some(idx) = + self.files.iter().position(|f| *f == name) + else { + continue; + }; let selected = self.selected == Some(idx); + let in_book = self.is_manuscript(&name); // The selected file's fields are read live from // the buffer (so unsaved edits show); others come // from the cache filled on open/save. let meta = if selected { FileMeta::from_markdown( - &self.buffer, + &self.document(), &self.config.draft_marker, ) } else { @@ -220,14 +327,16 @@ impl App { } else { 0.0 }; - let label_w = - (ui.available_width() - reserve).max(24.0); + let label_w = (row_width + - indent + - HANDLE_W + - reserve) + .max(24.0); + let text = egui::RichText::new(base_name(&name)); + let text = if in_book { text } else { text.weak() }; let mut label = ui.add_sized( [label_w, 20.0], - egui::SelectableLabel::new( - selected, - base_name(&name), - ), + egui::SelectableLabel::new(selected, text), ); if let Some(tooltip) = &tooltip { label = label.on_hover_text(tooltip); @@ -243,6 +352,14 @@ impl App { 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}")); + } }) .response; @@ -292,9 +409,9 @@ impl App { .small() .weak(), ); - ui.allocate_space(egui::vec2(ui.available_width(), 0.0)); }) .response; + let target = full_width_row(ui, &target, "\u{0}root-drop"); if drop_highlight(ui, &target) { if let Some(payload) = target.dnd_release_payload::() { dropped = Some(FileDrop { @@ -360,6 +477,18 @@ impl App { 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() { @@ -397,6 +526,41 @@ fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool { true } + + +/// 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; + +/// Room left after a folder's name for its file count and the manuscript tag. +const COUNT_W: f32 = 86.0; + +/// A response covering the whole visible width of `ui` at the row's height. +/// +/// Rows want to be drop targets across their full width, but *claiming* that +/// width makes the content as wide as the panel, and the panel is sized from +/// its content — which grows it, frame after frame. Interacting with a rect +/// taken from the clip rectangle sidesteps that: it is the visible area, not +/// the content, so it cannot feed back into the layout. +fn full_width_row(ui: &egui::Ui, response: &egui::Response, key: &str) -> egui::Response { + let rect = egui::Rect::from_x_y_ranges(ui.clip_rect().x_range(), response.rect.y_range()); + ui.interact(rect, egui::Id::new(("row", key)), egui::Sense::hover()) +} + +/// A compact badge for a `Status:` value: the first letters of its words, so a +/// long stage name still fits beside a file name. +pub(super) fn status_tag(status: &str) -> String { + let initials: String = status + .split_whitespace() + .filter_map(|w| w.chars().find(|c| c.is_alphanumeric())) + .collect(); + if initials.chars().count() >= 2 { + initials.to_uppercase() + } else { + status.chars().take(4).collect::().to_uppercase() + } +} + /// Resolve a chapter's title: a non-empty manual `override_title` wins, then the /// `# Title:` header value, otherwise the chapter's 1-based position followed by /// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding). @@ -512,7 +676,7 @@ mod tests { RowKind::Folder { count, first } => { format!("{}:dir({count},{first}):{}", r.depth, r.path) } - RowKind::File { idx } => format!("{}:file({idx}):{}", r.depth, r.path), + RowKind::File => format!("{}:file:{}", r.depth, r.path), }) .collect() } @@ -521,7 +685,7 @@ mod tests { fn flat_files_get_no_folder_rows() { assert_eq!( sketch(&["a.md", "b.md"], &[]), - vec!["0:file(0):a.md", "0:file(1):b.md"] + vec!["0:file:a.md", "0:file:b.md"] ); } @@ -531,9 +695,9 @@ mod tests { sketch(&["p/a.md", "p/b.md", "top.md"], &[]), vec![ "0:dir(2,0):p", - "1:file(0):p/a.md", - "1:file(1):p/b.md", - "0:file(2):top.md", + "1:file:p/a.md", + "1:file:p/b.md", + "0:file:top.md", ] ); } @@ -546,8 +710,8 @@ mod tests { // `p` counts both files; `q` only its own. "0:dir(2,0):p", "1:dir(1,0):p/q", - "2:file(0):p/q/a.md", - "1:file(1):p/b.md", + "2:file:p/q/a.md", + "1:file:p/b.md", ] ); } @@ -556,7 +720,7 @@ mod tests { fn collapsing_a_folder_hides_its_files_but_keeps_its_header() { assert_eq!( sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]), - vec!["0:dir(2,0):p", "0:file(2):top.md"] + vec!["0:dir(2,0):p", "0:file:top.md"] ); } @@ -564,12 +728,12 @@ mod tests { fn collapsing_hides_nested_headers_too() { assert_eq!( sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]), - vec!["0:dir(2,0):p", "0:file(2):top.md"] + vec!["0:dir(2,0):p", "0:file:top.md"] ); // Collapsing only the inner folder leaves the outer one drawn. assert_eq!( sketch(&["p/q/a.md", "p/b.md"], &["p/q"]), - vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file(1):p/b.md"] + vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file:p/b.md"] ); } @@ -581,7 +745,7 @@ mod tests { vec![ "0:dir(1,0):part-1", "0:dir(1,1):part-10", - "1:file(1):part-10/b.md", + "1:file:part-10/b.md", ] ); } @@ -592,9 +756,9 @@ mod tests { sketch(&["p/a.md", "q/b.md"], &[]), vec![ "0:dir(1,0):p", - "1:file(0):p/a.md", + "1:file:p/a.md", "0:dir(1,1):q", - "1:file(1):q/b.md", + "1:file:q/b.md", ] ); } diff --git a/src/app/grammar.rs b/src/app/grammar.rs index 9603cf9..2dc2a18 100644 --- a/src/app/grammar.rs +++ b/src/app/grammar.rs @@ -113,31 +113,52 @@ impl App { } /// Apply a LanguageTool suggestion to the buffer, then shift the remaining - /// matches so their highlights stay valid. - pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) { + /// matches so their highlights stay valid. Returns whether the fix landed. + pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) -> bool { // Only safe while the buffer still matches what was checked. if self.buffer != self.lt_checked_text { - return; + return false; } - if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) { - self.dirty = true; - self.find_needs_refresh = true; - self.lt_checked_text = self.buffer.clone(); - self.lt_status = match self.lt_matches.len() { - 0 => "No issues remaining".to_string(), - 1 => "1 issue".to_string(), - n => format!("{n} issues"), - }; + if !splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) { + return false; } + self.dirty = true; + self.find_needs_refresh = true; + self.lt_checked_text = self.buffer.clone(); + self.lt_status = match self.lt_matches.len() { + 0 => "No issues remaining".to_string(), + 1 => "1 issue".to_string(), + n => format!("{n} issues"), + }; + true } /// Apply suggestion `rep_idx` of the match at `match_idx` from whichever - /// source currently owns the issues (LanguageTool or the spell checker). + /// source currently owns the issues (LanguageTool or the spell checker), + /// then send the editor to the text that was rewritten. pub(super) fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) { - match self.issue_source() { + // Where the replacement will sit once spliced in, worked out before the + // splice shifts every match after it. + let target = self.current_matches().get(match_idx).and_then(|m| { + m.replacements + .get(rep_idx) + .map(|rep| (m.start, m.start + rep.len())) + }); + let applied = match self.issue_source() { IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx), IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx), - IssueSource::None => {} + IssueSource::None => false, + }; + if applied { + self.issue_jump = target; + } + } + + /// Send the editor to the issue at `idx`, selecting it so it is obvious + /// which words the panel entry was talking about. + pub(super) fn jump_to_issue(&mut self, idx: usize) { + if let Some(m) = self.current_matches().get(idx) { + self.issue_jump = Some((m.start, m.end)); } } @@ -348,6 +369,7 @@ impl App { } let mut apply: Option<(usize, usize)> = None; + let mut jump: Option = None; egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { @@ -359,12 +381,19 @@ impl App { egui::Color32::from_rgb(0x3B, 0x82, 0xF6) }; ui.label(egui::RichText::new("●").color(col)); + // The snippet and the explanation are both live: + // clicking either takes the editor to the issue. + let mut clicked = false; if !item.snippet.is_empty() { - ui.label( + clicked |= go_to_label( + ui, egui::RichText::new(format!("“{}”", item.snippet)).strong(), ); } - ui.label(&item.message); + clicked |= go_to_label(ui, egui::RichText::new(&item.message)); + if clicked { + jump = Some(item.idx); + } }); ui.horizontal_wrapped(|ui| { ui.add_space(16.0); @@ -384,13 +413,25 @@ impl App { } }); + // A fix wins over a bare jump: it moves the editor there too. if let Some((i, j)) = apply { self.apply_current_fix(i, j); + } else if let Some(i) = jump { + self.jump_to_issue(i); } }); } } +/// A panel label that behaves like a link to a place in the text: hand cursor, +/// hover hint, and `true` on the frame it is clicked. +fn go_to_label(ui: &mut egui::Ui, text: egui::RichText) -> bool { + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("Go to this spot in the text") + .clicked() +} + /// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and /// remap the remaining matches. Returns whether a replacement was made (false if /// the indices are out of range or the match's bounds aren't valid boundaries). diff --git a/src/app/mod.rs b/src/app/mod.rs index bc6e6d7..0fc34e7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,9 +16,12 @@ use std::time::{Duration, Instant}; mod autocomplete; mod beats; +mod characters; +mod diff; mod editor; mod file_list; mod find; +mod outline; mod project; mod grammar; mod spelling; @@ -33,6 +36,7 @@ use self::autocomplete::*; use self::file_list::*; use self::grammar::*; use self::project::*; +use self::spelling::*; use self::style::*; use self::util::*; @@ -70,6 +74,8 @@ struct FileMeta { goal: Option, /// Prose (body) word count captured with the rest of this metadata. prose_words: usize, + /// The `Status:` line, if any — the revision stage this file has reached. + status: Option, } impl FileMeta { @@ -81,14 +87,10 @@ impl FileMeta { pov: h.pov, goal: h.goal, prose_words: count_words(&h.body), + status: crate::preprocess::field(text, marker, "Status"), } } - /// Whether this entry carries anything worth caching/showing. - fn has_display(&self) -> bool { - self.slug.is_some() || self.pov.is_some() || self.goal.is_some() - } - /// The tooltip text — a `POV:` line then the slug synopsis — or `None` when /// the file carries neither field. fn tooltip(&self) -> Option { @@ -163,6 +165,10 @@ struct IssueItem { pub struct App { config: Config, + /// The manuscript folder within the workspace when the workspace is a + /// project root — files under it are the book, everything else is + /// reference. `None` when the workspace is itself the manuscript. + manuscript_dir: Option, /// Ordered markdown files, as workspace-relative paths with `/` separators /// (`part-1/ch-03.md`). Always held in folder-tree order — see /// [`crate::order::tree_order`] — so this list reads exactly as the file @@ -177,8 +183,11 @@ pub struct App { /// Editing buffer for the selected file's chapter title override. title_input: String, selected: Option, - /// Editor contents for the selected file. + /// Editor contents for the selected file. While the header is collapsed + /// this is the prose only; [`App::document`] puts the file back together. buffer: String, + /// The editorial header lifted out of `buffer` while it is collapsed. + header_stash: Option, dirty: bool, /// Editable copy of the workspace path shown in the top bar. workspace_input: String, @@ -268,9 +277,33 @@ pub struct App { spell_rx: Option>, /// One-line status for the spell checker (dictionary name, count, or error). spell_status: String, + /// Names and invented terms the checker accepts on top of the dictionary, + /// loaded from the workspace's `wordlist.json`. + wordlist: crate::spell::WordList, + /// Whether the word-list window is open. + show_wordlist: bool, + /// When set, the file panel shows only files whose `Status:` matches. + status_filter: Option, + /// The project's cast, read from its character sheets. + characters: Vec, + /// Whether the Characters window is open. + show_characters: bool, + /// Whether the manuscript-details window is open. + show_manuscript_settings: bool, + /// Whether the Outline window is open. + show_outline: bool, + /// Whether the diff window is open, and what it is showing. + show_diff: bool, + diff_text: String, + diff_title: String, + /// Text field for adding a word by hand. + wordlist_input: String, /// Index (into the currently displayed matches) of the word a right-click /// suggestion menu is open for, if any. spell_menu: Option, + /// Byte range in `buffer` the editor should select and scroll into view next + /// frame, set when an issue is clicked in the results panel. + issue_jump: Option<(usize, usize)>, /// Whether the Mistral settings window is open. show_mistral_settings: bool, /// Whether the new-file template settings window is open. @@ -312,12 +345,14 @@ impl App { workspace_input: config.workspace.display().to_string(), export_input: config.export_path.display().to_string(), config, + manuscript_dir: None, files: Vec::new(), collapsed: HashSet::new(), titles: HashMap::new(), title_input: String::new(), selected: None, buffer: String::new(), + header_stash: None, dirty: false, new_name: String::new(), rename_input: String::new(), @@ -360,7 +395,19 @@ impl App { spell_last_edit: None, spell_rx: None, spell_status: String::new(), + wordlist: crate::spell::WordList::default(), + show_wordlist: false, + status_filter: None, + characters: Vec::new(), + show_characters: false, + show_manuscript_settings: false, + show_outline: false, + show_diff: false, + diff_text: String::new(), + diff_title: String::new(), + wordlist_input: String::new(), spell_menu: None, + issue_jump: None, show_mistral_settings: false, show_template_settings: false, beats_rx: None, @@ -471,6 +518,26 @@ impl eframe::App for App { self.beats_window(ctx); } + if self.show_wordlist { + self.wordlist_window(ctx); + } + + if self.show_characters { + self.characters_window(ctx); + } + + if self.show_manuscript_settings { + self.manuscript_settings_window(ctx); + } + + if self.show_outline { + self.outline_window(ctx); + } + + if self.show_diff { + self.diff_window(ctx); + } + if self.show_cheatsheet { crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet); } diff --git a/src/app/outline.rs b/src/app/outline.rs new file mode 100644 index 0000000..1406819 --- /dev/null +++ b/src/app/outline.rs @@ -0,0 +1,160 @@ +//! The Outline window: how much of the project's outline is actually written. + +use super::*; + +/// One outline file and the state of the beats in it. +pub(super) struct OutlineFile { + /// Workspace-relative path, so it can be opened in the editor. + pub rel: String, + pub beats: Vec, +} + +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. + pub(super) fn outline_dir(&self) -> Option { + 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, "Outline") { + return Some(current.join(name)); + } + dir = current.parent(); + } + None + } + + /// Read every outline file and the beats in it. + pub(super) fn read_outline(&self) -> Vec { + let Some(dir) = self.outline_dir() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for path in markdown_files_under(&dir) { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let beats = crate::outline::beats(&text); + if beats.is_empty() { + continue; + } + let rel = self + .relative_to_workspace(&path) + .unwrap_or_else(|| path.display().to_string()); + out.push(OutlineFile { rel, beats }); + } + out + } + + /// The Outline window: a checklist of the beats the scaffold names. + pub(super) fn outline_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_outline; + let mut close = false; + let mut open_file: Option = None; + let files = self.read_outline(); + + egui::Window::new("Outline") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(440.0) + .show(ctx, |ui| { + match self.outline_dir() { + Some(dir) => { + ui.label( + egui::RichText::new(format!("Scaffold in {}", dir.display())) + .small() + .weak(), + ); + } + None => { + ui.label( + egui::RichText::new( + "No outline folder found near this workspace.", + ) + .weak(), + ); + } + } + if files.is_empty() { + ui.label( + egui::RichText::new( + "No beat prompts found. The template writes them as HTML \ + comments like .", + ) + .small() + .weak(), + ); + } + + let total: usize = files.iter().map(|f| f.beats.len()).sum(); + let done: usize = files + .iter() + .map(|f| crate::outline::progress(&f.beats).0) + .sum(); + if total > 0 { + ui.add_space(4.0); + ui.add( + egui::ProgressBar::new(done as f32 / total as f32) + .text(format!("{done} of {total} beats written")), + ); + } + ui.separator(); + + egui::ScrollArea::vertical() + .auto_shrink([false, true]) + .max_height(420.0) + .show(ui, |ui| { + for file in &files { + let (done, total) = crate::outline::progress(&file.beats); + egui::CollapsingHeader::new(format!( + "{} ({done}/{total})", + file.rel + )) + .id_salt(&file.rel) + .default_open(files.len() == 1) + .show(ui, |ui| { + 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() + }); + } + }); + } + }); + + ui.separator(); + if ui.button("Close").clicked() { + close = true; + } + }); + + if let Some(path) = open_file { + match self.files.iter().position(|f| *f == path) { + Some(idx) => self.select(idx), + None => self.status = format!("{path} is not in this workspace"), + } + } + self.show_outline = open && !close; + } +} diff --git a/src/app/project.rs b/src/app/project.rs index 5686460..c470632 100644 --- a/src/app/project.rs +++ b/src/app/project.rs @@ -114,21 +114,20 @@ impl App { } } - /// Point the workspace at a generated project: its configured drafting - /// subfolder when the template produced one, otherwise the project root. + /// Point the workspace at a generated project. The project *root* is opened + /// rather than the drafting folder: the app recognises the layout and treats + /// the drafting folder as the manuscript, keeping the characters, outline and + /// the rest reachable in the same tree. fn open_project(&mut self, project: &Path) { let subdir = self.config.project_open_subdir.trim(); - let (workspace, note) = match subdir { - "" => (project.to_path_buf(), String::new()), - sub if project.join(sub).is_dir() => (project.join(sub), String::new()), - sub => ( - project.to_path_buf(), - format!(" (no {sub} folder in it, opened the project root)"), - ), + let note = if subdir.is_empty() || project.join(subdir).is_dir() { + String::new() + } else { + format!(" (no {subdir} folder in it, so nothing is marked as the manuscript)") }; self.save_current(); - self.workspace_input = workspace.display().to_string(); - self.config.workspace = workspace; + self.workspace_input = project.display().to_string(); + self.config.workspace = project.to_path_buf(); // Export alongside the new project rather than into the previous one. self.config.export_path = project.join(format!( "{}.odt", @@ -217,7 +216,7 @@ impl App { if !subdir.is_empty() { ui.label( egui::RichText::new(format!( - "Then opens its {subdir} folder as the workspace." + "Then opens the project, with {subdir} as the manuscript." )) .small() .weak(), @@ -281,6 +280,71 @@ impl App { } } + /// Title and author written into exported documents. + pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_manuscript_settings; + let mut close = false; + egui::Window::new("Manuscript details") + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(380.0) + .show(ctx, |ui| { + let mut save_now = false; + egui::Grid::new("manuscript_details_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("Title:"); + // Resolved first: the field borrows `self.config` mutably. + let fallback = self + .workspace() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Manuscript") + .to_string(); + let r = ui + .add( + egui::TextEdit::singleline(&mut self.config.manuscript_title) + .hint_text(fallback) + .desired_width(240.0), + ) + .on_hover_text("Blank uses the project folder's name"); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Author:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.manuscript_author) + .desired_width(240.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + }); + ui.label( + egui::RichText::new( + "Written into exported .odt files as their document \ + properties, which is what a word processor shows under \ + File ▸ Properties.", + ) + .small() + .weak(), + ); + ui.separator(); + if ui.button("Close").clicked() { + close = true; + } + if save_now { + self.config.save(); + } + }); + let now_open = open && !close; + if self.show_manuscript_settings && !now_open { + self.config.save(); + } + self.show_manuscript_settings = now_open; + } + /// Settings for **File ▸ New project…**: which template to render, how to /// run it, and the credentials its hooks read. pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) { @@ -331,12 +395,18 @@ impl App { .num_columns(2) .spacing([10.0, 8.0]) .show(ui, |ui| { - ui.label("Open subfolder:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.project_open_subdir) + ui.label("Manuscript folder:"); + let r = ui + .add( + egui::TextEdit::singleline( + &mut self.config.project_open_subdir, + ) .hint_text("06-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”.", + ); save_now |= r.lost_focus(); ui.end_row(); diff --git a/src/app/spelling.rs b/src/app/spelling.rs index b7efd0d..8d911b9 100644 --- a/src/app/spelling.rs +++ b/src/app/spelling.rs @@ -6,21 +6,23 @@ use super::*; impl App { /// Apply a spell-check suggestion to the buffer, keeping the remaining /// misspelling underlines aligned. - pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) { + pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) -> bool { if self.buffer != self.spell_checked_text { - return; + return false; } - if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) { - self.dirty = true; - self.find_needs_refresh = true; - // Keep these matches valid without forcing a full re-check. - self.spell_checked_text = self.buffer.clone(); - self.spell_status = match self.spell_matches.len() { - 0 => "No spelling issues".to_string(), - 1 => "1 spelling issue".to_string(), - n => format!("{n} spelling issues"), - }; + if !splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) { + return false; } + self.dirty = true; + self.find_needs_refresh = true; + // Keep these matches valid without forcing a full re-check. + self.spell_checked_text = self.buffer.clone(); + self.spell_status = match self.spell_matches.len() { + 0 => "No spelling issues".to_string(), + 1 => "1 spelling issue".to_string(), + n => format!("{n} spelling issues"), + }; + true } // ---- Spell check (offline) --------------------------------------------- @@ -125,13 +127,253 @@ impl App { } self.spell_dirty = false; let text = self.buffer.clone(); + let extra = self.wordlist.clone(); let (tx, rx) = std::sync::mpsc::channel(); self.spell_rx = Some(rx); let ctx = ctx.clone(); std::thread::spawn(move || { - let matches = crate::spell::check(&dict, &text); + let matches = crate::spell::check(&dict, &extra, &text); let _ = tx.send((text, matches)); ctx.request_repaint(); }); } + + // ---- The project word list --------------------------------------------- + + /// Accept `word` from now on: add it to the workspace's word list, persist + /// it, and drop the underlines it was causing without waiting for a + /// re-check. + pub(super) fn add_to_dictionary(&mut self, word: &str) { + let word = word.trim().to_string(); + if word.is_empty() { + return; + } + if !self.wordlist.insert(&word) { + self.spell_status = format!("“{word}” is already in the word list"); + return; + } + self.persist_wordlist(); + self.drop_accepted_matches(); + self.spell_status = format!("Added “{word}” — {}", Self::spell_count_status( + self.spell_matches.len() + )); + } + + /// Stop accepting `word`, so it is flagged again. + pub(super) fn remove_from_dictionary(&mut self, word: &str) { + if self.wordlist.remove(word) { + self.persist_wordlist(); + // The word has to be found again, which needs a full pass. + self.spell_dirty = true; + self.spell_last_edit = None; + self.spell_status = format!("Removed “{word}” from the word list"); + } + } + + pub(super) fn persist_wordlist(&mut self) { + if let Err(e) = crate::spell::write_wordlist(self.workspace(), &self.wordlist) { + self.status = format!("Could not save the word list: {e}"); + } + } + + /// Clear the matches the word list now accepts, so an added word stops being + /// underlined immediately rather than after the next debounce. + fn drop_accepted_matches(&mut self) { + let text = self.spell_checked_text.clone(); + let list = self.wordlist.clone(); + self.spell_matches.retain(|m| { + text.get(m.start..m.end) + .is_none_or(|w| !list.accepts(w)) + }); + self.spell_menu = None; + } + + /// 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`. + pub(super) fn characters_dir(&self) -> Option { + /// How far up to look before giving up; deep enough for the layout, + /// shallow enough not to wander into the home directory. + const MAX_UP: usize = 3; + let mut dir = Some(self.workspace()); + for _ in 0..=MAX_UP { + let current = dir?; + if let Ok(read) = std::fs::read_dir(current) { + for entry in read.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if crate::spell::is_characters_dir(name) && entry.path().is_dir() { + return Some(entry.path()); + } + } + } + dir = current.parent(); + } + None + } + + /// Add every name found in the project's character sheets to the word list. + /// Returns how many were new, or an error message. + pub(super) fn harvest_character_names(&mut self) -> Result { + let dir = self + .characters_dir() + .ok_or_else(|| "No character folder found near this workspace".to_string())?; + let mut added = 0usize; + let mut sheets = 0usize; + for path in markdown_files_under(&dir) { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + sheets += 1; + for name in crate::spell::names_from_sheet(&text) { + if self.wordlist.insert(&name) { + added += 1; + } + } + } + if sheets == 0 { + return Err(format!("No character sheets in {}", dir.display())); + } + if added > 0 { + self.persist_wordlist(); + self.drop_accepted_matches(); + } + Ok(added) + } + /// The workspace's word list: what the checker accepts on top of the + /// dictionary, where it came from, and how to take a word back out. + pub(super) fn wordlist_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_wordlist; + let mut close = false; + let mut remove: Option = None; + let mut harvest = false; + + egui::Window::new("Word list") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(360.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new( + "Names and invented terms the spell checker accepts. Saved in \ + the workspace as wordlist.json, so it is committed with the \ + manuscript.", + ) + .small() + .weak(), + ); + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.wordlist_input) + .hint_text("add a word") + .desired_width(200.0), + ); + if ui.button("+ Add").clicked() { + let word = self.wordlist_input.trim().to_string(); + if !word.is_empty() { + self.add_to_dictionary(&word); + self.wordlist_input.clear(); + } + } + }); + + let found = self.characters_dir(); + ui.horizontal(|ui| { + if ui + .add_enabled(found.is_some(), egui::Button::new("👤 Add character names")) + .on_hover_text(match &found { + Some(dir) => format!("Read the sheets in {}", dir.display()), + None => "No character folder found near this workspace".to_string(), + }) + .clicked() + { + harvest = true; + } + }); + + ui.separator(); + ui.label( + egui::RichText::new(format!("{} word(s)", self.wordlist.len())).strong(), + ); + egui::ScrollArea::vertical() + .auto_shrink([false, true]) + .max_height(260.0) + .show(ui, |ui| { + if self.wordlist.is_empty() { + ui.label( + egui::RichText::new( + "Nothing yet — right-click a underlined word in the \ + editor to add it.", + ) + .weak(), + ); + } + for word in self.wordlist.words() { + ui.horizontal(|ui| { + if ui + .small_button("✖") + .on_hover_text("Flag this word again") + .clicked() + { + remove = Some(word.clone()); + } + ui.label(word); + }); + } + }); + + ui.separator(); + if ui.button("Close").clicked() { + close = true; + } + }); + + if let Some(word) = remove { + self.remove_from_dictionary(&word); + } + if harvest { + self.spell_status = match self.harvest_character_names() { + Ok(0) => "No new names in the character sheets".to_string(), + Ok(n) => format!("Added {n} name(s) from the character sheets"), + Err(e) => format!("✖ {e}"), + }; + } + self.show_wordlist = open && !close; + } +} + +/// Every `*.md` file at or below `dir`, one level of nesting at a time. Project +/// folders group their files into subfolders — character sheets into +/// `main_characters`, the outline into `scene_breakdown` — so a flat read of the +/// top level would miss all of them. +pub(super) fn markdown_files_under(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + // A guard against a symlink pointing back up the tree. + let mut budget = 512usize; + while let Some(current) = stack.pop() { + let Ok(read) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in read.flatten() { + if budget == 0 { + return out; + } + budget -= 1; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("md")) + { + out.push(path); + } + } + } + out.sort(); + out } diff --git a/src/app/ui.rs b/src/app/ui.rs index b892c33..e66b11b 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -176,7 +176,7 @@ impl App { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if let Some(idx) = self.selected { let name = &self.files[idx]; - let total = count_words(&self.buffer); + let total = count_words(&self.document()); let baseline = self.session_start_counts.get(name).copied().unwrap_or(0); let delta = total as i64 - baseline as i64; @@ -193,6 +193,21 @@ impl App { "Words in the current file · net change since this session opened", ); + // The whole book's length, which is the number that + // actually tells you where the manuscript stands. + ui.separator(); + ui.label( + egui::RichText::new(format!( + "{} total", + thousands(self.manuscript_words()) + )) + .weak(), + ) + .on_hover_text( + "Prose words across the whole manuscript (reference \ + files are not counted)", + ); + // Progress toward the file's Word Count Target, if set. if let Some((goal, prose)) = goal { let (frac, color, text) = goal_progress(goal, prose); @@ -240,6 +255,17 @@ impl App { ui.close_menu(); self.export_odt(); } + if ui + .button("Export chapters + master (.odm)") + .on_hover_text( + "One .odt per chapter in a chapters/ folder, plus an \ + .odm master that links them", + ) + .clicked() + { + ui.close_menu(); + self.export_master(); + } ui.separator(); if ui.button("Quit").clicked() { ui.close_menu(); @@ -247,12 +273,30 @@ impl App { } }); ui.menu_button("Edit", |ui| { + if ui + .button("⇄ Changes since last commit…") + .on_hover_text("Diff the open file against its committed version") + .clicked() + { + ui.close_menu(); + self.open_diff(); + } + ui.separator(); if ui.button("🔍 Find / Replace…").clicked() { ui.close_menu(); self.open_find(true); } }); ui.menu_button("Tools", |ui| { + if ui.button("☑ Outline…").clicked() { + ui.close_menu(); + self.show_outline = true; + } + if ui.button("👥 Characters…").clicked() { + ui.close_menu(); + self.show_characters = true; + } + ui.separator(); let busy = self.beats_rx.is_some(); if ui .add_enabled( @@ -272,6 +316,30 @@ impl App { { self.config.save(); } + if ui + .checkbox( + &mut self.config.show_reference_files, + "Show reference files", + ) + .on_hover_text( + "List a project's characters, outline and scratch pad in \ + the file panel alongside the manuscript", + ) + .clicked() + { + self.config.save(); + } + if ui + .checkbox(&mut self.config.collapse_header, "Hide header in editor") + .on_hover_text( + "Edit the prose alone, with the fields above the draft \ + marker folded away", + ) + .clicked() + { + self.apply_header_collapse(); + self.config.save(); + } ui.checkbox(&mut self.show_lt_panel, "Grammar panel"); ui.checkbox(&mut self.show_log, "Git log"); }); @@ -292,6 +360,14 @@ impl App { ui.close_menu(); self.show_project_settings = true; } + if ui.button("Manuscript details…").clicked() { + ui.close_menu(); + self.show_manuscript_settings = true; + } + if ui.button("Word list…").clicked() { + ui.close_menu(); + self.show_wordlist = true; + } }); ui.menu_button("Help", |ui| { if ui.button("📝 Markdown cheatsheet").clicked() { @@ -357,7 +433,7 @@ impl App { }); ui.horizontal(|ui| { ui.label("Chapter title:"); - let auto = self.auto_title(idx, &self.buffer); + let auto = self.auto_title(idx, &self.document()); let resp = ui.add( egui::TextEdit::singleline(&mut self.title_input) .hint_text(format!("auto: {auto}")) @@ -372,6 +448,7 @@ impl App { .weak(), ); }); + self.header_toggle(ui); ui.separator(); if self.show_find { @@ -450,6 +527,48 @@ impl App { self.decline_pending_repo(); } } + + + /// A single line saying whether the editorial header is folded away, and + /// letting you flip it. + /// + /// It exists so a hidden header can never be mistaken for a file that has + /// none: the editor is showing less than the file holds, and that has to be + /// visible without opening a menu. + 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() + { + return; + } + ui.horizontal(|ui| { + let (label, hover) = if collapsed { + let n = self.hidden_field_count(); + ( + format!("▸ header hidden ({n} field{})", if n == 1 { "" } else { "s" }), + "Show the fields above the draft marker", + ) + } else { + ( + "▾ header shown".to_string(), + "Fold the fields away and edit the prose alone", + ) + }; + if ui + .add(egui::Button::new(egui::RichText::new(label).small().weak()).frame(false)) + .on_hover_text(hover) + .clicked() + { + self.config.collapse_header = !collapsed; + self.apply_header_collapse(); + self.config.save(); + } + }); + } + } /// Very small markdown-ish preview (headings emphasised, everything else plain). diff --git a/src/app/workspace.rs b/src/app/workspace.rs index bed848f..1484021 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -17,26 +17,59 @@ impl App { self.status = format!("Cannot create workspace: {e}"); return; } - self.files = order::resolve_order(&ws); + // The project root has moved, so the export follows it. + self.retarget_export(); + // Which folder is the manuscript has to be settled before the file + // list is read, since it decides what counts as a chapter. + 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.wordlist = crate::spell::read_wordlist(&ws); // Drop overrides for files that no longer exist. self.titles.retain(|name, _| self.files.contains(name)); self.detect_repo(&ws); self.selected = None; self.buffer.clear(); + self.header_stash = None; self.dirty = false; self.pending_delete = false; self.clear_lt(); self.session_start_counts = self.snapshot_counts(); self.file_meta = self.snapshot_file_meta(); self.rebuild_field_names(); + self.load_characters(); self.autocomplete = None; self.collapsed.clear(); - if !self.files.is_empty() { - self.select(0); + if let Some(idx) = first_listed(&self.files, self.manuscript_dir.as_deref()) { + self.select(idx); } self.persist_order(); - self.status = format!("{} file(s) in {}", self.files.len(), ws.display()); + self.status = match &self.manuscript_dir { + Some(dir) => { + let chapters = self.manuscript_files().len(); + format!( + "{chapters} chapter(s) in {dir}, {} reference file(s) — {}", + self.files.len() - chapters, + ws.display() + ) + } + None => format!("{} file(s) in {}", self.files.len(), ws.display()), + }; + } + + /// Point the export at the opened workspace, which is the project root. + /// + /// A file name the user picked deliberately is carried across; one that + /// merely echoed the previous project's folder is re-derived, so exports + /// land beside the book being worked on instead of piling up in the + /// project left behind. + pub(super) fn retarget_export(&mut self) { + let path = export_path_for(&self.config.workspace, &self.config.export_path); + if path != self.config.export_path { + self.config.export_path = path; + self.config.save(); + } + self.export_input = self.config.export_path.display().to_string(); } /// Work out the git situation for a freshly opened workspace. @@ -94,6 +127,39 @@ impl App { self.status = "Not using the enclosing git repository".to_string(); } + /// Work out whether the opened folder is a **project root** — a folder + /// holding the manuscript alongside characters, outline and the rest — and + /// 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 + /// [`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. + pub(super) fn detect_manuscript_dir(&self, ws: &Path) -> Option { + order::child_dir_matching(ws, self.config.project_open_subdir.trim()) + } + + /// Whether `path` (workspace-relative) is part of the manuscript proper: + /// ordered, numbered and exported. Everything else in a project — character + /// sheets, outline, scratch pad — is reference material that the app will + /// happily open and edit but leaves out of the book. + pub(super) fn is_manuscript(&self, path: &str) -> bool { + match &self.manuscript_dir { + Some(dir) => is_within(path, dir), + None => true, + } + } + + /// The manuscript's files, in order, paired with their chapter index. + pub(super) fn manuscript_files(&self) -> Vec<(usize, &String)> { + self.files + .iter() + .filter(|path| self.is_manuscript(path)) + .enumerate() + .collect() + } + /// Read every file and return its current on-disk word count. pub(super) fn snapshot_counts(&self) -> HashMap { self.files @@ -105,19 +171,56 @@ impl App { .collect() } - /// Read every file and return its cached header info (slug/POV/goal/prose), - /// for files that carry anything worth showing in the list. + /// Read every file and return its cached header info (slug/POV/goal/status + /// and prose length). Every file is cached, not only the ones with a tooltip + /// to show, because the status column and the project word total need a + /// figure for each. pub(super) fn snapshot_file_meta(&self) -> HashMap { self.files .iter() - .filter_map(|name| { + .map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - let meta = FileMeta::from_markdown(&text, &self.config.draft_marker); - meta.has_display().then(|| (name.clone(), meta)) + ( + name.clone(), + FileMeta::from_markdown(&text, &self.config.draft_marker), + ) }) .collect() } + /// 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 { + let open = self.selected.and_then(|i| self.files.get(i)); + self.manuscript_files() + .into_iter() + .map(|(_, name)| { + if Some(name) == open { + let h = + crate::preprocess::parse(&self.document(), &self.config.draft_marker); + count_words(&h.body) + } else { + self.file_meta.get(name).map_or(0, |m| m.prose_words) + } + }) + .sum() + } + + /// Every distinct `Status:` value in the manuscript, for the filter menu. + pub(super) fn known_statuses(&self) -> Vec { + let mut seen: Vec = Vec::new(); + for name in self.files.iter() { + let Some(status) = self.file_meta.get(name).and_then(|m| m.status.clone()) else { + continue; + }; + if !seen.iter().any(|s| s.eq_ignore_ascii_case(&status)) { + seen.push(status); + } + } + seen.sort_by_key(|s| s.to_lowercase()); + seen + } + pub(super) fn persist_order(&self) { let _ = order::write_order(self.workspace(), &self.files); } @@ -146,24 +249,97 @@ impl App { self.workspace().join(name) } + // ---- Collapsing the editorial header ------------------------------------ + + /// The whole document: the editor's text with the collapsed header, if any, + /// put back in front of it. + /// + /// While the header is collapsed it is not in `buffer` at all, which is what + /// keeps every byte offset the editor works in — search matches, spelling + /// underlines, the caret — pointing at what is actually on screen. Anything + /// that wants the *file* rather than the view asks for this instead. + pub(super) fn document(&self) -> std::borrow::Cow<'_, str> { + match &self.header_stash { + Some(header) => std::borrow::Cow::Owned(format!("{header}{}", self.buffer)), + None => std::borrow::Cow::Borrowed(&self.buffer), + } + } + + /// Lift the header out of the buffer, leaving the prose. Does nothing for a + /// document with no draft marker — there is nothing to collapse. + pub(super) fn collapse_header(&mut self) { + if self.header_stash.is_some() { + return; + } + let Some((header, body)) = + crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker) + else { + return; + }; + let (header, body) = (header.to_string(), body.to_string()); + self.header_stash = Some(header); + self.buffer = body; + self.after_view_change(); + } + + /// Put the header back into the buffer. + pub(super) fn expand_header(&mut self) { + let Some(header) = self.header_stash.take() else { + return; + }; + self.buffer.insert_str(0, &header); + self.after_view_change(); + } + + /// Re-collapse or expand to match the setting, after the buffer is replaced. + pub(super) fn apply_header_collapse(&mut self) { + if self.config.collapse_header { + self.collapse_header(); + } else { + self.expand_header(); + } + } + + /// Everything keyed to the buffer's contents has to be recomputed when the + /// header moves in or out of it — but the file itself has not changed, so + /// this must not mark the document dirty. + fn after_view_change(&mut self) { + self.find_matches.clear(); + self.find_active = 0; + self.find_needs_refresh = true; + self.spell_matches.clear(); + self.spell_checked_text.clear(); + self.spell_dirty = true; + self.spell_last_edit = None; + self.spell_menu = None; + self.clear_lt(); + self.autocomplete = None; + } + + /// How many header fields are hidden right now, for the toggle's label. + pub(super) fn hidden_field_count(&self) -> usize { + match &self.header_stash { + Some(header) => crate::preprocess::fields(header, "").len(), + None => 0, + } + } + /// Save the in-memory buffer to disk if it has unsaved changes. pub(super) fn save_current(&mut self) { if let Some(idx) = self.selected { if self.dirty { if let Some(name) = self.files.get(idx).cloned() { let path = self.path_for(&name); - match std::fs::write(&path, &self.buffer) { + match std::fs::write(&path, self.document().as_bytes()) { Ok(_) => { self.dirty = false; self.status = format!("Saved {name}"); // Keep the file-list cache (slug/POV/goal/prose) in step. - let meta = - FileMeta::from_markdown(&self.buffer, &self.config.draft_marker); - if meta.has_display() { - self.file_meta.insert(name, meta); - } else { - self.file_meta.remove(&name); - } + let meta = FileMeta::from_markdown( + &self.document(), + &self.config.draft_marker, + ); + self.file_meta.insert(name, meta); self.merge_field_names_from_buffer(); } Err(e) => self.status = format!("Save failed: {e}"), @@ -193,7 +369,9 @@ impl App { return; }; let path = self.path_for(&name); + self.header_stash = None; self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); + self.apply_header_collapse(); self.selected = Some(idx); self.dirty = false; self.pending_delete = false; @@ -316,9 +494,12 @@ impl App { prune_empty_dirs(self.workspace(), parent_dir(&name)); self.selected = None; self.buffer.clear(); + self.header_stash = None; self.dirty = false; - if !self.files.is_empty() { - self.select(idx.min(self.files.len() - 1)); + if let Some(next) = + nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx) + { + self.select(next); } self.status = format!("Deleted {name}"); } @@ -329,6 +510,54 @@ impl App { self.pending_delete = false; } + /// Move the selected file into the project's archive folder: off the + /// manuscript, out of the panel, but still on disk. + /// + /// Deleting is destructive and a superseded scene is often worth keeping; + /// the archive folder is hidden from the scan, so archiving a file makes it + /// disappear from the tree without losing it. + pub(super) fn archive_selected(&mut self) { + let Some(idx) = self.selected else { return }; + let Some(name) = self.files.get(idx).cloned() else { + return; + }; + let archive = self.archive_dir(); + // Keep the file's shape inside the archive, so a scene from + // `Act 1/` lands in `10-Archive/Act 1/` rather than losing its place. + let dest = join_rel(&archive, &name); + if self.path_for(&dest).exists() { + self.status = format!("{dest} already exists"); + return; + } + self.save_current(); + if let Err(e) = self.relocate(&name, &dest) { + self.status = format!("Archive failed: {e}"); + return; + } + self.files.remove(idx); + self.persist_order(); + self.selected = None; + self.buffer.clear(); + self.header_stash = None; + self.dirty = false; + if let Some(next) = nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx) { + self.select(next); + } + self.status = format!("Archived {name} to {archive}"); + } + + /// The archive folder's name within the workspace: whatever the project + /// already calls it, otherwise the configured default. + pub(super) fn archive_dir(&self) -> String { + let configured = self.config.archive_folder.trim(); + let role = if configured.is_empty() { + "Archive" + } else { + configured + }; + order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string()) + } + pub(super) fn rename_selected(&mut self) { let Some(idx) = self.selected else { return }; // A path in the box (`part-2/ch-07`) both renames and moves the file. @@ -400,7 +629,7 @@ impl App { /// chapter number when padding is enabled, otherwise 1 (no padding). pub(super) fn index_pad_width(&self) -> usize { if self.config.zero_pad_index { - self.files.len().to_string().len().max(1) + self.manuscript_files().len().to_string().len().max(1) } else { 1 } @@ -419,17 +648,93 @@ impl App { /// Prose = the body below the draft marker, so header metadata isn't counted. pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { self.selected?; - let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker); + let header = crate::preprocess::parse(&self.document(), &self.config.draft_marker); let goal = header.goal?; Some((goal, count_words(&header.body))) } + /// Document properties for an export: the configured title and author, with + /// the title falling back to the project folder's own name. + pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta { + let title = match self.config.manuscript_title.trim() { + "" => self + .workspace() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Manuscript") + .to_string(), + set => set.to_string(), + }; + odt::DocMeta { + title, + author: self.config.manuscript_author.trim().to_string(), + subject: String::new(), + keywords: String::new(), + word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(), + chapter_count: chapters.len(), + } + } + + /// Export the manuscript as one `.odt` per chapter plus an `.odm` master + /// that links them, which is the shape the snowflake template expects. + /// + /// The per-chapter files are ordinary documents and are the useful part. + /// The master is a shell: LibreOffice does not follow its section links on + /// load without being asked to update them, so treat it as a starting point + /// rather than as the assembled book. + pub(super) fn export_master(&mut self) { + self.save_current(); + let chapters = self.collect_chapters(); + if chapters.is_empty() { + self.status = "Nothing to export — the manuscript is empty".to_string(); + return; + } + let meta = self.doc_meta(&chapters); + let out = PathBuf::from(self.export_input.trim()).with_extension("odm"); + if let Some(parent) = out.parent() { + let _ = std::fs::create_dir_all(parent); + } + match odt::export_master(&chapters, &meta, &out) { + Ok(written) => { + self.status = format!( + "Wrote {} chapter file(s) beside {}", + written.len(), + out.display() + ); + } + Err(e) => self.status = format!("Export failed: {e}"), + } + } + + /// The manuscript as chapters, ready for either export path. + fn collect_chapters(&self) -> Vec { + let marker = self.config.draft_marker.clone(); + let pad_width = self.index_pad_width(); + self.manuscript_files() + .into_iter() + .map(|(i, name)| { + let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); + let header = crate::preprocess::parse(&raw, &marker); + Chapter { + title: resolve_chapter_title( + self.titles.get(name).map(String::as_str), + header.title.as_deref(), + i, + pad_width, + ), + slug: header.slug.clone(), + markdown: header.body, + } + }) + .collect() + } + pub(super) fn export_odt(&mut self) { self.save_current(); let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); let mut chapters = Vec::new(); - for (i, name) in self.files.iter().enumerate() { + for (i, name) in self.manuscript_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. @@ -453,7 +758,8 @@ impl App { if let Some(parent) = out.parent() { let _ = std::fs::create_dir_all(parent); } - match odt::export(&chapters, &out) { + let meta = self.doc_meta(&chapters); + match odt::export(&chapters, &meta, &out) { Ok(_) => { self.config.export_path = out.clone(); self.config.save(); @@ -640,6 +946,73 @@ impl App { } } +/// Index of the first file the panel lists, which is the one to open a +/// workspace on. +/// +/// In a project the panel shows the manuscript, so landing on `files[0]` would +/// open whatever sorts first across the whole project — a scratch-pad note, +/// typically — and in a file the user cannot see in the tree. Reference files +/// are still the fallback, for a project whose manuscript folder is empty. +pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option { + let in_book = |name: &String| match manuscript_dir { + Some(dir) => is_within(name, dir), + None => true, + }; + files + .iter() + .position(in_book) + .or_else(|| (!files.is_empty()).then_some(0)) +} + +/// The listed file nearest `idx` after a removal: the next one down, else the +/// last one before it, else whatever is left. +pub(super) fn nearest_listed( + files: &[String], + manuscript_dir: Option<&str>, + idx: usize, +) -> Option { + let in_book = |i: &usize| match manuscript_dir { + Some(dir) => is_within(&files[*i], dir), + None => true, + }; + (idx..files.len()) + .find(in_book) + .or_else(|| (0..idx.min(files.len())).rev().find(in_book)) + .or_else(|| first_listed(files, manuscript_dir)) +} + +/// Where the manuscript exports to for `workspace`, given the `current` export +/// path. +/// +/// The folder is always the workspace (the project root). The file name is kept +/// when it looks chosen — anything but the fallback `manuscript` or an echo of +/// the folder the file sits in, both of which we generate ourselves. +fn export_path_for(workspace: &Path, current: &Path) -> PathBuf { + let stem = current.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let old_folder = current + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(""); + let generated = stem.is_empty() + || stem.eq_ignore_ascii_case("manuscript") + || stem.eq_ignore_ascii_case(old_folder); + let name = if generated { + let folder = workspace + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("manuscript"); + format!("{folder}.odt") + } else { + current + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("manuscript.odt") + .to_string() + }; + workspace.join(name) +} + /// Delete `dir` (workspace-relative) and every parent it leaves childless, so /// the tree stops drawing branches nothing lives in any more. `remove_dir` /// refuses to touch a non-empty directory, which is exactly the guard wanted @@ -801,6 +1174,49 @@ mod tests { assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}"); } + #[test] + fn opening_a_project_moves_a_generated_export_name_with_it() { + let out = export_path_for( + Path::new("/books/The Winter Gate"), + Path::new("/books/Salt Road/Salt Road.odt"), + ); + assert_eq!( + out, + PathBuf::from("/books/The Winter Gate/The Winter Gate.odt") + ); + } + + #[test] + fn a_chosen_export_name_survives_the_move_to_the_new_root() { + let out = export_path_for( + Path::new("/books/The Winter Gate"), + Path::new("/books/Salt Road/submission draft.odt"), + ); + assert_eq!( + out, + PathBuf::from("/books/The Winter Gate/submission draft.odt") + ); + } + + #[test] + fn the_default_export_name_is_re_derived_rather_than_kept() { + let out = export_path_for( + Path::new("/books/The Winter Gate"), + Path::new("/home/writer/Manuscript/manuscript.odt"), + ); + assert_eq!( + out, + PathBuf::from("/books/The Winter Gate/The Winter Gate.odt") + ); + } + + #[test] + fn reopening_the_same_workspace_leaves_the_export_untouched() { + let ws = Path::new("/books/The Winter Gate"); + let current = PathBuf::from("/books/The Winter Gate/submission draft.odt"); + assert_eq!(export_path_for(ws, ¤t), current); + } + /// Build a throwaway workspace containing `files` and hand back its path. fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf { let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}")); @@ -876,4 +1292,63 @@ mod tests { assert!(ws.is_dir()); let _ = std::fs::remove_dir_all(&ws); } + + fn v(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + /// The real shape of the bug: the scratch pad sorts first, so opening a + /// project landed on a note that the panel does not even list. + #[test] + fn opening_a_project_lands_in_the_manuscript() { + 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", + ]); + assert_eq!(first_listed(&files, Some("06-First Draft")), Some(2)); + } + + #[test] + fn a_plain_folder_still_opens_on_its_first_file() { + let files = v(&["a.md", "b.md"]); + assert_eq!(first_listed(&files, None), Some(0)); + assert_eq!(first_listed(&[], None), None); + } + + /// A project whose manuscript folder holds nothing yet still has to open + /// something rather than nothing. + #[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)); + } + + #[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", + ]); + // Removing index 1 leaves the following manuscript file at index 1. + assert_eq!(nearest_listed(&files, Some("06-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)); + } + + #[test] + fn a_removal_never_lands_on_a_reference_file() { + let files = v(&["00-Scratch Pad/note.md", "06-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)); + } + + #[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); + } } diff --git a/src/characters.rs b/src/characters.rs new file mode 100644 index 0000000..003ab00 --- /dev/null +++ b/src/characters.rs @@ -0,0 +1,367 @@ +//! The project's cast, read from its character sheets. +//! +//! A sheet is an ordinary markdown file whose level-1 heading is the character's +//! name and whose header fields describe them: +//! +//! ```text +//! # Bixby Reynolds +//! +//! ### The broken truth-seeker +//! +//! Age: 37 +//! Description: An assistant philosophy professor +//! ``` +//! +//! Both `# Bixby Reynolds` and `# Name: Bixby Reynolds` are in use, so both are +//! understood. Reading the cast lets the app do things it otherwise cannot: say +//! which scenes a character appears in, keep the summary grid in step with the +//! sheets, and stop underlining everybody's name. + +use std::path::{Path, PathBuf}; + +/// One character, as described by their sheet. +#[derive(Debug, Clone)] +pub struct Character { + /// The name from the sheet's heading. + pub name: String, + /// The sheet itself, so the app can open it. + pub file: PathBuf, + /// The sheet's header fields, in the order written. + pub fields: Vec, + /// The descriptor heading under the name, where the template puts `### Slug` + /// and a filled sheet puts something like `### The broken truth-seeker`. + pub subtitle: Option, +} + +impl Character { + /// Look up one of the sheet's fields, case-insensitively. + pub fn field(&self, name: &str) -> Option<&str> { + self.fields + .iter() + .find(|f| f.name.eq_ignore_ascii_case(name)) + .map(|f| f.value.as_str()) + .filter(|v| !v.is_empty()) + } + + /// The short descriptor shown beside the name: a `Slug:` field, else the + /// descriptor heading under the name, else the opening of the description. + /// + /// Both sheet styles are covered, since one writes `## Slug: The haunted + /// murderer` and the other writes it as a bare `### The broken truth-seeker`. + pub fn tagline(&self) -> Option<&str> { + self.field("Slug") + .or(self.subtitle.as_deref()) + .or_else(|| self.field("Description")) + } + + /// Whether `mention` names this character. A scene's `Characters:` line uses + /// whatever the author types — usually a first name — so any single part of + /// the full name counts, and vice versa. + pub fn is_named_by(&self, mention: &str) -> bool { + let mention = mention.trim(); + if mention.is_empty() { + return false; + } + if self.name.eq_ignore_ascii_case(mention) { + return true; + } + // "Bixby" names "Bixby Reynolds"; "Bixby Reynolds" names "Bixby". + self.name + .split_whitespace() + .any(|part| part.eq_ignore_ascii_case(mention)) + || mention + .split_whitespace() + .any(|part| part.eq_ignore_ascii_case(&self.name)) + } +} + +/// Placeholder headings the template ships, which name no one. +const PLACEHOLDERS: &[&str] = &["name", "protagonist", "antagonist", "character"]; + +/// Section headings a sheet uses structurally, which are not descriptors. +const SECTION_HEADINGS: &[&str] = &["slug", "summary", "notes", "things to note"]; + +/// The descriptor heading beneath the name: the first heading below level one +/// that is neither a field line nor one of the sheet's structural sections. +pub fn subtitle(markdown: &str) -> Option { + markdown.lines().find_map(|line| { + let trimmed = line.trim(); + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + if !(2..=6).contains(&hashes) { + return None; + } + let text = trimmed[hashes..].trim(); + // A `## Slug: value` line is a field, and is read as one. + if text.is_empty() || text.contains(':') { + return None; + } + (!SECTION_HEADINGS.contains(&text.to_lowercase().as_str())) + .then(|| text.to_string()) + }) +} + +/// The character's name from a sheet: its first level-1 heading, with a `Name:` +/// label removed. `None` for a sheet still carrying the template's placeholder. +pub fn heading_name(markdown: &str) -> Option { + let heading = markdown + .lines() + .find_map(|line| line.trim().strip_prefix("# ").map(str::trim))?; + let name = match heading.split_once(':') { + Some((label, value)) if label.trim().eq_ignore_ascii_case("name") => value.trim(), + _ => heading, + }; + let name = name.trim(); + if name.is_empty() || PLACEHOLDERS.contains(&name.to_lowercase().as_str()) { + return None; + } + Some(name.to_string()) +} + +/// Read every character sheet at or below `dir`, sorted by name. +/// +/// `marker` is the draft marker, so a sheet that happens to carry prose below +/// one does not have it read as fields. +pub fn read_sheets(dir: &Path, marker: &str) -> Vec { + let mut out = Vec::new(); + for file in markdown_files_under(dir) { + let Ok(text) = std::fs::read_to_string(&file) else { + continue; + }; + let Some(name) = heading_name(&text) else { + continue; + }; + out.push(Character { + name, + file, + fields: crate::preprocess::fields(&text, marker), + subtitle: subtitle(&text), + }); + } + out.sort_by_key(|c| c.name.to_lowercase()); + out +} + +/// Every `*.md` file at or below `dir`. Sheets are grouped into +/// `main_characters` / `supporting_characters`, so a flat read misses them. +fn markdown_files_under(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + // Guards against a symlink pointing back up the tree. + let mut budget = 512usize; + while let Some(current) = stack.pop() { + let Ok(read) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in read.flatten() { + if budget == 0 { + return out; + } + budget -= 1; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("md")) + { + out.push(path); + } + } + } + out.sort(); + out +} + +/// The project's existing character-grid file, if it has one: a markdown file +/// under `dir` whose name says "grid". Reusing it keeps the generated table +/// where the project already expects to find it. +pub fn grid_file(dir: &Path) -> Option { + markdown_files_under(dir).into_iter().find(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|stem| stem.to_lowercase().contains("grid")) + }) +} + +/// Columns the generated grid carries, beyond the name. +pub const GRID_COLUMNS: &[&str] = &["Age", "Description", "Motivation"]; + +/// Render the cast as a markdown table, for the project's character grid. +/// +/// The grid is a summary of the sheets, and a hand-maintained one drifts out of +/// date the moment a sheet changes — so it is generated instead. A markdown +/// table is used rather than the raw HTML the template ships, because the app +/// can render and diff markdown. +pub fn grid_markdown(characters: &[Character]) -> String { + let mut out = String::from( + "\n\n\ + # Character grid\n\n", + ); + if characters.is_empty() { + out.push_str("_No character sheets found._\n"); + return out; + } + out.push_str("| Name |"); + for column in GRID_COLUMNS { + out.push_str(&format!(" {column} |")); + } + out.push_str("\n|---|"); + out.push_str(&"---|".repeat(GRID_COLUMNS.len())); + out.push('\n'); + + for character in characters { + out.push_str(&format!("| {} |", escape_cell(&character.name))); + for column in GRID_COLUMNS { + let value = character.field(column).unwrap_or(""); + out.push_str(&format!(" {} |", escape_cell(value))); + } + out.push('\n'); + } + out +} + +/// The grid is a quick-look summary, so a cell that runs to a paragraph is +/// trimmed to its opening. +const MAX_CELL_CHARS: usize = 110; + +/// Make a value safe inside a markdown table cell: pipes would end the cell, +/// newlines would end the row, and a paragraph would make the table unreadable. +fn escape_cell(value: &str) -> String { + let flat = value.replace('|', "\\|").replace(['\n', '\r'], " "); + if flat.chars().count() <= MAX_CELL_CHARS { + return flat; + } + let mut out: String = flat.chars().take(MAX_CELL_CHARS).collect(); + // Cut at a word boundary so the trim does not land mid-word. + if let Some(space) = out.rfind(' ') { + out.truncate(space); + } + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sheet(text: &str) -> Character { + Character { + name: heading_name(text).unwrap_or_default(), + file: PathBuf::from("x.md"), + fields: crate::preprocess::fields(text, "### Rough Draft:"), + subtitle: subtitle(text), + } + } + + #[test] + fn a_name_is_read_from_either_heading_style() { + assert_eq!( + heading_name("# Bixby Reynolds\n\n### The broken truth-seeker\n").as_deref(), + Some("Bixby Reynolds") + ); + assert_eq!( + heading_name("# Name: Jonathan Bronski\n").as_deref(), + Some("Jonathan Bronski") + ); + } + + #[test] + fn an_unfilled_template_sheet_names_no_one() { + assert_eq!(heading_name("# Name\n\n### Slug\n\nAge:\n"), None); + assert_eq!(heading_name("# Protagonist\n"), None); + assert_eq!(heading_name("no heading\n"), None); + assert_eq!(heading_name("## Not level one\n"), None); + } + + #[test] + fn a_scene_mention_matches_by_any_part_of_the_name() { + let c = sheet("# Bixby Reynolds\n"); + assert!(c.is_named_by("Bixby")); + assert!(c.is_named_by("bixby")); + assert!(c.is_named_by("Reynolds")); + assert!(c.is_named_by("Bixby Reynolds")); + assert!(!c.is_named_by("Paige")); + assert!(!c.is_named_by("")); + } + + /// Two Reynoldses both answer to the surname, which is honest: the app + /// cannot know which one a bare "Reynolds" meant. + #[test] + fn a_shared_surname_matches_both_bearers() { + assert!(sheet("# Bixby Reynolds\n").is_named_by("Reynolds")); + assert!(sheet("# Paige Reynolds\n").is_named_by("Reynolds")); + } + + #[test] + fn the_tagline_prefers_the_slug_then_the_heading_then_the_description() { + let with_slug = sheet("# A\n\n## Slug: the seeker\n\nDescription: a professor\n"); + assert_eq!(with_slug.tagline(), Some("the seeker")); + // The other real sheet style puts the descriptor in a bare heading. + let with_heading = sheet("# A\n\n### The broken truth-seeker\n\nDescription: long\n"); + assert_eq!(with_heading.tagline(), Some("The broken truth-seeker")); + let without = sheet("# A\n\nDescription: a professor\n"); + assert_eq!(without.tagline(), Some("a professor")); + assert_eq!(sheet("# A\n").tagline(), None); + } + + #[test] + fn structural_headings_are_not_descriptors() { + // The unfilled template leaves `### Slug` and `### Summary:` in place. + assert_eq!(subtitle("# A\n\n### Slug\n\n### Summary:\n"), None); + assert_eq!(subtitle("# A\n\n### Summary\n"), None); + // A level-one heading is the name, not a descriptor. + assert_eq!(subtitle("# A\n"), None); + } + + #[test] + fn a_long_cell_is_trimmed_at_a_word_boundary() { + let long = "x".repeat(40) + " " + &"y".repeat(200); + let cast = vec![sheet(&format!("# A\n\nDescription: {long}\n"))]; + let grid = grid_markdown(&cast); + let row = grid.lines().find(|l| l.starts_with("| A |")).unwrap(); + assert!(row.contains('…'), "expected an ellipsis in {row}"); + assert!(row.len() < 200, "cell was not trimmed: {}", row.len()); + assert!(!row.contains("yyyyyyyyyyyyyyyyyyyy")); + } + + #[test] + fn the_grid_has_a_row_per_character_and_a_header() { + let cast = vec![ + sheet("# Bixby Reynolds\n\nAge: 37\nDescription: A professor\nMotivation: Grief\n"), + sheet("# Paige Reynolds\n\nAge: 37\n"), + ]; + let grid = grid_markdown(&cast); + assert!(grid.contains("| Name | Age | Description | Motivation |")); + assert!(grid.contains("| Bixby Reynolds | 37 | A professor | Grief |")); + // A sheet missing a field still gets a well-formed row. + assert!(grid.contains("| Paige Reynolds | 37 | | |")); + // Every row has the same number of separators as the header. + let bars: Vec = grid + .lines() + .filter(|l| l.starts_with('|')) + .map(|l| l.matches('|').count()) + .collect(); + assert!(bars.windows(2).all(|w| w[0] == w[1]), "ragged table: {bars:?}"); + } + + #[test] + fn a_pipe_in_a_value_cannot_break_the_table() { + let cast = vec![sheet("# A\n\nDescription: one | two\n")]; + let grid = grid_markdown(&cast); + assert!(grid.contains("one \\| two"), "got {grid}"); + } + + #[test] + fn an_empty_cast_still_renders_something_readable() { + let grid = grid_markdown(&[]); + assert!(grid.contains("No character sheets found")); + } + + #[test] + fn the_grid_says_it_is_generated() { + let grid = grid_markdown(&[sheet("# A\n")]); + assert!(grid.starts_with(" +//! +//! +//! ``` +//! +//! The app strips comments everywhere else, which means these files render and +//! export as blank — there is nothing to see and no way to tell how much of the +//! outline is actually written. This module reads the scaffold back: the beats +//! it names, and which of them have prose under them yet. + +/// One beat of an outline, and whether it has been written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Beat { + /// The heading this beat sits under, e.g. `Act I – Setup`. + pub section: String, + /// The beat's name, taken from the start of its prompt comment. + pub name: String, + /// Whether any prose has been written for it. + pub filled: bool, +} + +/// 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. +pub fn beats(markdown: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut section = String::new(); + let mut rest = markdown; + + // Text seen since the last prompt, which decides whether it is filled. + let mut pending_prose = String::new(); + + while let Some(start) = rest.find("") else { + break; + }; + let body = &rest[start + 4..start + 4 + end]; + if let Some(name) = prompt_name(body) { + out.push(Beat { + section: section.clone(), + name, + filled: false, + }); + } + 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 + } + settle(&mut out, &mut pending_prose); + out +} + +/// 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) { + if let Some(last) = beats.last_mut() { + last.filled = true; + } + } + pending.clear(); +} + +/// Whether a run of text contains anything a writer would call content — +/// headings and blank lines do not count. +fn has_prose(text: &str) -> bool { + text.lines() + .map(str::trim) + .any(|line| !line.is_empty() && !line.starts_with('#')) +} + +/// The last markdown heading in a run of text, if any. +fn last_heading(text: &str) -> Option { + text.lines().rev().find_map(|line| { + let trimmed = line.trim(); + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + if hashes == 0 { + return None; + } + let name = trimmed[hashes..].trim(); + (!name.is_empty()).then(|| name.to_string()) + }) +} + +/// The beat name at the start of a prompt comment: everything before its first +/// colon, which is how the template writes them (`Midpoint: A major turning…`). +fn prompt_name(body: &str) -> Option { + let text = body.trim(); + if text.is_empty() { + return None; + } + let name = match text.split_once(':') { + Some((name, _)) => name.trim(), + // A comment with no colon is a note, not a beat prompt. + None => return None, + }; + // Prompt names are labels; a long one is a sentence that happened to have + // a colon in it. + if name.is_empty() || name.split_whitespace().count() > 8 { + return None; + } + Some(name.to_string()) +} + +/// How many of `beats` are written, and how many there are. +pub fn progress(beats: &[Beat]) -> (usize, usize) { + (beats.iter().filter(|b| b.filled).count(), beats.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCAFFOLD: &str = "\ +## Act I – Setup (≈ 25 % of the story) + + + + + +## Act II – Confrontation + + +"; + + #[test] + fn an_untouched_scaffold_has_no_beats_written() { + let beats = beats(SCAFFOLD); + assert_eq!(beats.len(), 3); + assert!(beats.iter().all(|b| !b.filled)); + assert_eq!(progress(&beats), (0, 3)); + } + + #[test] + fn beat_names_and_their_sections_are_read() { + let beats = beats(SCAFFOLD); + assert_eq!(beats[0].name, "Opening Image / Hook"); + assert_eq!(beats[0].section, "Act I – Setup (≈ 25 % of the story)"); + assert_eq!(beats[1].name, "Inciting Incident"); + assert_eq!(beats[2].name, "Midpoint"); + assert_eq!(beats[2].section, "Act II – Confrontation"); + } + + #[test] + fn prose_after_a_prompt_marks_it_written() { + let written = "\ +## Act I + + + +Ada stands on the ice, counting bolts. + + +"; + let beats = beats(written); + assert_eq!(progress(&beats), (1, 2)); + assert!(beats[0].filled, "the beat with prose under it"); + assert!(!beats[1].filled, "the one still empty"); + } + + #[test] + fn a_heading_does_not_count_as_prose() { + let text = "\n\n## Act II\n\n\n"; + let beats = beats(text); + assert_eq!(progress(&beats), (0, 2)); + } + + #[test] + fn prose_after_the_last_prompt_still_counts() { + let text = "\n\nShe opens the gate.\n"; + let beats = beats(text); + assert_eq!(progress(&beats), (1, 1)); + } + + #[test] + fn a_comment_without_a_colon_is_a_note_not_a_beat() { + let text = "\n\n\n"; + let beats = beats(text); + assert_eq!(beats.len(), 1); + assert_eq!(beats[0].name, "Climax"); + } + + #[test] + fn an_unterminated_comment_does_not_hang_or_panic() { + let beats = beats("\n\n\n"; + assert_eq!(progress(&beats(text)), (0, 1)); + } +} diff --git a/src/preprocess.rs b/src/preprocess.rs index e6a59ea..a3e1076 100644 --- a/src/preprocess.rs +++ b/src/preprocess.rs @@ -88,6 +88,132 @@ pub fn strip_comments(s: &str) -> String { out } +/// Split a document at its draft marker into `(header, body)`, where the header +/// includes the marker line itself and its newline. +/// +/// The split is exact: `header + body` is the original text, byte for byte, so +/// a caller can lift the header out and put it back without touching what the +/// author wrote. `None` when the document has no marker to split at. +pub fn split_at_marker<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a str)> { + let marker_norm = marker.trim().to_ascii_lowercase(); + if marker_norm.is_empty() { + return None; + } + let mut offset = 0usize; + for line in text.split_inclusive('\n') { + if line.trim().to_ascii_lowercase().starts_with(&marker_norm) { + let end = offset + line.len(); + return Some((&text[..end], &text[end..])); + } + offset += line.len(); + } + None +} + +/// One `#* Name: value` line from a document's editorial header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Field { + /// The field name as written, e.g. `Word Count Target`. + pub name: String, + /// Everything after the colon, trimmed. May be empty for an unfilled field. + pub value: String, +} + +/// Every `#* Name: value` line in a document's header, in the order written. +/// +/// This is the generic form of what [`parse`] pulls out for the few fields the +/// exporter cares about: the scene card, the status column and the character +/// index all read fields the exporter has no opinion about, so they read them +/// from here rather than each re-implementing the line format. +/// +/// Only the header is considered — lines above `marker`, or the whole document +/// when there is none — so a `## Setting:` written inside the prose is not +/// mistaken for card metadata. +pub fn fields(markdown: &str, marker: &str) -> Vec { + let text = strip_comments(markdown); + let marker_norm = marker.trim().to_ascii_lowercase(); + let mut out = Vec::new(); + for line in text.lines() { + if !marker_norm.is_empty() + && line.trim().to_ascii_lowercase().starts_with(&marker_norm) + { + break; + } + if let Some(field) = field_line(line) { + out.push(field); + } + } + out +} + +/// Look up one field by name, case-insensitively. Empty values give `None`, so +/// an unfilled `## POV:` reads the same as an absent one. +pub fn field(markdown: &str, marker: &str, name: &str) -> Option { + fields(markdown, marker) + .into_iter() + .find(|f| f.name.eq_ignore_ascii_case(name.trim())) + .map(|f| f.value) + .filter(|v| !v.is_empty()) +} + +/// 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 { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +/// If `line` is a header field, split it into name and value. +/// +/// Two forms are in use and both are accepted, because real files mix them: +/// scene cards write `## POV: Bixby`, while character sheets often write a bare +/// `Age: 37`. The bare form is the looser one, so it carries a stricter test — +/// its name may only be letters, spaces, hyphens and apostrophes — which keeps +/// ordinary prose out. A line of dialogue like `Bixby: run` would still be read +/// as a field; only the header is scanned, so that matters just for documents +/// with no draft marker, where the header is the whole file. +fn field_line(line: &str) -> Option { + let trimmed = line.trim_start(); + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + let (rest, headed) = if hashes > 0 { + let after_hash = &trimmed[hashes..]; + if !after_hash.starts_with(char::is_whitespace) { + return None; + } + (after_hash.trim(), true) + } else { + // A bare field must start the line, so an indented or wrapped line of + // prose is never mistaken for one. + if line.starts_with(char::is_whitespace) { + return None; + } + (trimmed, false) + }; + let (name, value) = rest.split_once(':')?; + let name = name.trim(); + // A field name is a short label, not a sentence. The longest in real use is + // three words ("Word Count Target"), so four leaves headroom while rejecting + // most prose headings that happen to contain a colon. + if name.is_empty() || name.split_whitespace().count() > 4 || name.ends_with('.') { + return None; + } + if !headed + && !name + .chars() + .all(|c| c.is_alphabetic() || c == ' ' || c == '-' || c == '\'') + { + return None; + } + Some(Field { + name: name.to_string(), + value: value.trim().to_string(), + }) +} + /// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or /// `## Word Count Target:` — return which one and its value. Matches one or more /// leading `#` followed by whitespace, case-insensitively, so the field can sit @@ -284,3 +410,166 @@ mod tests { assert_eq!(parse(md2, MARKER).pov, None); } } + +#[cfg(test)] +mod field_tests { + use super::*; + + /// A real scene card from the snowflake plot folder. + const CARD: &str = "\ +# Title: + +# Slug: 2026 - Bixby + +## POV: Bixby + +## Characters: Bixby, Mom, Kayla + +## Setting: The Old Reynold's House + +## Word Count Target: 1500 - 2000 + +### Rough Draft: + +She stood there. The setting: a house. Not a field: prose. +"; + + #[test] + fn every_header_field_is_read_in_order() { + let names: Vec = fields(CARD, "### Rough Draft:") + .into_iter() + .map(|f| f.name) + .collect(); + assert_eq!( + names, + ["Title", "Slug", "POV", "Characters", "Setting", "Word Count Target"] + ); + } + + #[test] + fn a_field_is_found_by_name_whatever_its_case() { + assert_eq!(field(CARD, "### Rough Draft:", "POV").as_deref(), Some("Bixby")); + assert_eq!(field(CARD, "### Rough Draft:", "pov").as_deref(), Some("Bixby")); + assert_eq!( + field(CARD, "### Rough Draft:", "Setting").as_deref(), + Some("The Old Reynold's House") + ); + // An unfilled field reads as absent rather than as an empty string. + assert_eq!(field(CARD, "### Rough Draft:", "Title"), None); + assert_eq!(field(CARD, "### Rough Draft:", "Nope"), None); + } + + /// The body is where prose lives; a colon in it is not card metadata. + #[test] + fn prose_below_the_marker_is_not_scanned() { + assert_eq!(field(CARD, "### Rough Draft:", "The setting"), None); + let names: Vec = fields(CARD, "### Rough Draft:") + .into_iter() + .map(|f| f.name) + .collect(); + assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("Not a field"))); + } + + #[test] + fn a_comma_list_splits_into_items() { + assert_eq!(value_items("Bixby, Mom, Kayla"), ["Bixby", "Mom", "Kayla"]); + assert_eq!(value_items(" Bixby ,, Kayla "), ["Bixby", "Kayla"]); + assert!(value_items("").is_empty()); + assert_eq!(value_items("Bixby"), ["Bixby"]); + } + + #[test] + fn a_prose_heading_is_not_a_field() { + // A heading long enough to be a sentence is a heading, not metadata. + assert!(field_line("## And then she said to her: run").is_none()); + assert!(field_line("## A very long heading indeed that goes on: yes").is_none()); + // The longest field name actually in use still parses. + assert_eq!( + field_line("## Word Count Target: 1500").map(|f| f.name), + Some("Word Count Target".to_string()) + ); + assert!(field_line("## No colon here").is_none()); + // A bare field line is accepted — character sheets are written that way. + assert_eq!( + field_line("Age: 37"), + Some(Field { name: "Age".into(), value: "37".into() }) + ); + // But not one whose label could not be a label. + assert!(field_line("At 17, Bixby was hurt: badly").is_none()); + assert!(field_line(" indented: value").is_none()); + assert!(field_line("2026 - Bixby: something").is_none()); + assert!(field_line("#NoSpace: value").is_none()); + // But a short label is. + assert_eq!( + field_line("## POV: Bixby"), + Some(Field { name: "POV".into(), value: "Bixby".into() }) + ); + } + + #[test] + fn comments_are_stripped_before_fields_are_read() { + let text = "\n## POV: Bixby\n"; + assert_eq!(field(text, "", "POV").as_deref(), Some("Bixby")); + } + + #[test] + fn without_a_marker_the_whole_document_is_scanned() { + assert_eq!(field("## POV: Ada\n\nprose\n", "", "POV").as_deref(), Some("Ada")); + } +} + +#[cfg(test)] +mod split_tests { + use super::*; + + const DOC: &str = "# Title: A\n\n## POV: Ada\n\n### Rough Draft:\n\nShe waited.\n"; + + #[test] + fn the_split_is_lossless() { + let (header, body) = split_at_marker(DOC, "### Rough Draft:").unwrap(); + assert_eq!(format!("{header}{body}"), DOC, "rejoining must be exact"); + assert!(header.ends_with("### Rough Draft:\n")); + assert_eq!(body, "\nShe waited.\n"); + } + + #[test] + fn the_marker_is_matched_loosely_like_everywhere_else() { + // Case and trailing text on the marker line are tolerated. + let doc = "# A\n### rough draft: \n\nprose\n"; + let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); + assert!(header.ends_with("### rough draft: \n")); + assert_eq!(body, "\nprose\n"); + } + + #[test] + fn a_document_without_a_marker_does_not_split() { + assert!(split_at_marker("# A\n\nprose\n", "### Rough Draft:").is_none()); + // An empty marker disables splitting, as it disables header parsing. + assert!(split_at_marker(DOC, "").is_none()); + assert!(split_at_marker(DOC, " ").is_none()); + } + + #[test] + fn only_the_first_marker_splits() { + let doc = "# A\n### Rough Draft:\nfirst\n### Rough Draft:\nsecond\n"; + let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); + assert_eq!(header, "# A\n### Rough Draft:\n"); + assert!(body.starts_with("first\n")); + } + + #[test] + fn a_marker_on_the_last_line_leaves_an_empty_body() { + let doc = "# A\n### Rough Draft:"; + let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); + assert_eq!(header, doc); + assert_eq!(body, ""); + } + + #[test] + fn multibyte_text_splits_on_a_character_boundary() { + let doc = "# Título: Café\n### Rough Draft:\nprosa — sí\n"; + let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); + assert_eq!(format!("{header}{body}"), doc); + assert_eq!(body, "prosa — sí\n"); + } +} diff --git a/src/spell.rs b/src/spell.rs index a00984e..d2b9513 100644 --- a/src/spell.rs +++ b/src/spell.rs @@ -13,7 +13,7 @@ use crate::langtool::Match; use spellbook::Dictionary; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; // --- Bundled dictionaries (SCOWL-derived, permissive license; see the files @@ -139,6 +139,160 @@ fn search_dirs() -> Vec { dirs } +// ---- The project word list -------------------------------------------------- +// +// Character names, place names and invented terms are unknown to any Hunspell +// dictionary, so without somewhere to record them a novel's cast is underlined +// on every line forever. The list lives in the workspace beside `order.json` +// and `titles.json`, so it is committed to git and travels with the project. + +/// Name of the file (inside the workspace) that stores the accepted words. +pub const WORDLIST_FILE: &str = "wordlist.json"; + +/// Words the spell checker should accept on top of the loaded dictionary. +/// +/// Matching is case-insensitive, and a listed word also covers its possessive +/// and plural forms — the tokenizer hands over `Bixby's` as a single token, so +/// a bare `Bixby` would otherwise not cover it. +#[derive(Debug, Default, Clone)] +pub struct WordList { + /// Display forms, sorted and deduplicated case-insensitively. + words: Vec, + /// Lowercased forms, for lookup. + lookup: HashSet, +} + +impl WordList { + /// Build from any iterator of words, discarding blanks and duplicates. + pub fn from_words(words: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let mut list = WordList::default(); + for word in words { + list.insert(word.as_ref()); + } + list + } + + /// Add a word. Returns whether it was new. + pub fn insert(&mut self, word: &str) -> bool { + let trimmed = word.trim(); + if trimmed.is_empty() { + return false; + } + let lower = trimmed.to_lowercase(); + if !self.lookup.insert(lower) { + return false; + } + self.words.push(trimmed.to_string()); + self.words + .sort_by_key(|w| (w.to_lowercase(), w.to_string())); + true + } + + /// Remove a word (case-insensitively). Returns whether it was there. + pub fn remove(&mut self, word: &str) -> bool { + let lower = word.trim().to_lowercase(); + if !self.lookup.remove(&lower) { + return false; + } + self.words.retain(|w| w.to_lowercase() != lower); + true + } + + /// Whether the checker should accept this token. + pub fn accepts(&self, word: &str) -> bool { + if self.lookup.is_empty() { + return false; + } + stems(word).any(|stem| self.lookup.contains(&stem.to_lowercase())) + } + + /// The accepted words in display order. + pub fn words(&self) -> &[String] { + &self.words + } + + pub fn len(&self) -> usize { + self.words.len() + } + + pub fn is_empty(&self) -> bool { + self.words.is_empty() + } +} + +/// A token and the forms of it a listed word should also cover: the possessive +/// (`Bixby's`), the plural (`Bixbys`) and the plural possessive (`Bixbys'`). +fn stems(word: &str) -> impl Iterator { + let mut out: Vec<&str> = vec![word]; + // Possessive first, then whatever plural is left underneath it. + let base = word + .strip_suffix("'s") + .or_else(|| word.strip_suffix("\u{2019}s")) + .or_else(|| word.strip_suffix('\'')) + .or_else(|| word.strip_suffix('\u{2019}')) + .unwrap_or(word); + if base != word { + out.push(base); + } + if let Some(singular) = base.strip_suffix('s').filter(|s| s.len() >= 2) { + out.push(singular); + } + out.into_iter() +} + +/// Read the workspace's word list. Missing or invalid gives an empty list, so a +/// hand-edited file can never stop the checker from running. +pub fn read_wordlist(workspace: &std::path::Path) -> WordList { + let path = workspace.join(WORDLIST_FILE); + let words: Vec = match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).unwrap_or_default(), + Err(_) => Vec::new(), + }; + WordList::from_words(words) +} + +/// Persist the word list as a sorted JSON array, so git diffs stay readable. +pub fn write_wordlist(workspace: &std::path::Path, list: &WordList) -> std::io::Result<()> { + let path = workspace.join(WORDLIST_FILE); + let text = serde_json::to_string_pretty(list.words()).unwrap_or_else(|_| "[]".to_string()); + std::fs::write(path, text) +} + +// ---- Harvesting names from character sheets --------------------------------- + +/// Whether a directory name looks like a project's character folder, allowing +/// for the numeric prefixes the snowflake layout uses (`03-Characters`). +pub fn is_characters_dir(name: &str) -> bool { + crate::order::dir_matches(name, "Characters") + || crate::order::dir_matches(name, "Character") +} + +/// Pull the name words out of a character sheet, so the cast stops being +/// underlined. Each part is listed separately, since prose says "Bixby" far +/// more often than "Bixby Reynolds". +/// +/// The name itself comes from [`crate::characters::heading_name`], which is +/// also what the Characters window reads — one rule for what a sheet is called. +pub fn names_from_sheet(markdown: &str) -> Vec { + let Some(name) = crate::characters::heading_name(markdown) else { + return Vec::new(); + }; + name.split_whitespace() + .map(|word| { + word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '\u{2019}') + }) + .filter(|word| { + word.chars().filter(|c| c.is_alphabetic()).count() >= 2 + && word.chars().next().is_some_and(char::is_uppercase) + }) + .map(str::to_string) + .collect() +} + /// How many distinct misspellings we compute suggestions for per check. Beyond /// this the words are still underlined but carry no suggestions (a right-click /// falls back to computing them on demand). Keeps a document full of unknown @@ -146,14 +300,16 @@ fn search_dirs() -> Vec { const MAX_SUGGEST_WORDS: usize = 250; /// Spell-check the prose in `text`, returning a spelling [`Match`] for every -/// unknown word (byte offsets into `text`, best suggestions first). -pub fn check(dict: &Dictionary, text: &str) -> Vec { +/// unknown word (byte offsets into `text`, best suggestions first). Words in +/// `extra` — the project's own names and invented terms — are accepted as if +/// they were in the dictionary. +pub fn check(dict: &Dictionary, extra: &WordList, text: &str) -> Vec { let mut out = Vec::new(); let mut cache: HashMap<&str, Vec> = HashMap::new(); let mut suggested = 0usize; for (offset, word) in prose_words(text) { - if should_skip(word) || dict.check(word) { + if should_skip(word) || dict.check(word) || extra.accepts(word) { continue; } let replacements = if let Some(cached) = cache.get(word) { @@ -303,8 +459,8 @@ mod tests { .load() .unwrap(); // "realise" is British; "color" (American) is not en-GB. - assert!(check(&dict, "realise").is_empty()); - assert_eq!(check(&dict, "color").len(), 1); + assert!(check(&dict, &WordList::default(), "realise").is_empty()); + assert_eq!(check(&dict, &WordList::default(), "color").len(), 1); } #[test] @@ -349,7 +505,7 @@ mod tests { fn check_flags_misspellings_with_correct_offsets() { let dict = en_ca(); let text = "The quikc brown fox."; - let matches = check(&dict, text); + let matches = check(&dict, &WordList::default(), text); assert_eq!(matches.len(), 1, "only 'quikc' is misspelled"); let m = &matches[0]; assert_eq!(&text[m.start..m.end], "quikc"); @@ -366,8 +522,8 @@ mod tests { fn check_respects_canadian_spelling() { let dict = en_ca(); // "colour" is correct in en-CA; "color" is not. - assert!(check(&dict, "colour").is_empty()); - assert_eq!(check(&dict, "color").len(), 1); + assert!(check(&dict, &WordList::default(), "colour").is_empty()); + assert_eq!(check(&dict, &WordList::default(), "color").len(), 1); } #[test] @@ -376,7 +532,7 @@ mod tests { // The é is two bytes; a misspelling after it must still map to the right // bytes (whether or not "café" itself is in the dictionary). let text = "café qmzxk"; - let matches = check(&dict, text); + let matches = check(&dict, &WordList::default(), text); let target = matches .iter() .find(|m| text.get(m.start..m.end) == Some("qmzxk")); @@ -389,4 +545,156 @@ mod tests { .collect::>() ); } + + // ---- The project word list --------------------------------------------- + + #[test] + fn a_listed_word_is_accepted_whatever_its_case() { + let list = WordList::from_words(["Bixby"]); + assert!(list.accepts("Bixby")); + assert!(list.accepts("bixby")); + assert!(list.accepts("BIXBY")); + assert!(!list.accepts("Bixbee")); + } + + /// The tokenizer keeps the apostrophe, so `Bixby's` arrives as one token; a + /// bare `Bixby` in the list has to cover it or the possessive stays flagged. + #[test] + fn a_listed_name_covers_its_possessive_and_plural() { + let list = WordList::from_words(["Bixby", "Reynolds"]); + assert!(list.accepts("Bixby's")); + assert!(list.accepts("Bixby\u{2019}s"), "curly apostrophe too"); + assert!(list.accepts("Bixbys")); + assert!(list.accepts("Bixbys'")); + // A name that already ends in s keeps working in every form. + assert!(list.accepts("Reynolds")); + assert!(list.accepts("Reynolds'")); + assert!(list.accepts("Reynolds's")); + } + + #[test] + fn an_empty_list_accepts_nothing() { + let list = WordList::default(); + assert!(!list.accepts("Bixby")); + assert!(!list.accepts("")); + assert!(list.is_empty()); + } + + #[test] + fn adding_is_idempotent_and_case_insensitive() { + let mut list = WordList::default(); + assert!(list.insert("Bixby")); + assert!(!list.insert("Bixby"), "the same word twice is not new"); + assert!(!list.insert("bixby"), "nor is it new in another case"); + assert!(!list.insert(" "), "and blanks are not words"); + assert_eq!(list.len(), 1); + } + + #[test] + fn removing_takes_a_word_back_out() { + let mut list = WordList::from_words(["Bixby", "Paige"]); + assert!(list.remove("bixby"), "removal is case-insensitive too"); + assert!(!list.accepts("Bixby")); + assert!(list.accepts("Paige")); + assert!(!list.remove("Bixby"), "already gone"); + assert_eq!(list.len(), 1); + } + + #[test] + fn words_come_back_sorted_for_a_readable_diff() { + let list = WordList::from_words(["Paige", "bixby", "Kayla"]); + assert_eq!(list.words(), ["bixby", "Kayla", "Paige"]); + } + + #[test] + fn the_word_list_round_trips_through_the_workspace() { + let dir = std::env::temp_dir().join("md_manuscript_wordlist"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Nothing on disk yet is an empty list, not an error. + assert!(read_wordlist(&dir).is_empty()); + + let list = WordList::from_words(["Bixby", "Oyelaran"]); + write_wordlist(&dir, &list).unwrap(); + let back = read_wordlist(&dir); + assert_eq!(back.words(), ["Bixby", "Oyelaran"]); + // Stored as a plain JSON array, so a git diff reads well. + let raw = std::fs::read_to_string(dir.join(WORDLIST_FILE)).unwrap(); + assert!(raw.trim_start().starts_with('['), "got {raw}"); + + // A corrupt file must not stop the checker running. + std::fs::write(dir.join(WORDLIST_FILE), "not json").unwrap(); + assert!(read_wordlist(&dir).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_checker_accepts_listed_words() { + let dict = Dictionary::new(EN_CA_AFF, EN_CA_DIC).expect("dictionary parses"); + let text = "Bixby watched Kayla sleep."; + // Without the list, the invented names are flagged. + let flagged = check(&dict, &WordList::default(), text); + let words: Vec<&str> = flagged.iter().map(|m| &text[m.start..m.end]).collect(); + assert!(words.contains(&"Bixby"), "expected Bixby flagged, got {words:?}"); + + // With them listed, nothing is left. + let list = WordList::from_words(["Bixby", "Kayla"]); + assert!( + check(&dict, &list, text).is_empty(), + "listed names must not be flagged" + ); + // A real misspelling still is. + let typo = check(&dict, &list, "Bixby wathced Kayla."); + assert_eq!(typo.len(), 1); + assert_eq!(&"Bixby wathced Kayla."[typo[0].start..typo[0].end], "wathced"); + } + + // ---- Harvesting names from character sheets ---------------------------- + + #[test] + fn a_character_folder_is_recognised_with_or_without_its_prefix() { + assert!(is_characters_dir("03-Characters")); + assert!(is_characters_dir("Characters")); + assert!(is_characters_dir("characters")); + assert!(is_characters_dir("3 Character")); + assert!(!is_characters_dir("03-Outline")); + assert!(!is_characters_dir("character_grid")); + } + + /// Both heading styles are in use in real sheets. + #[test] + fn names_are_read_from_either_heading_style() { + assert_eq!( + names_from_sheet("# Bixby Reynolds\n\n### The broken truth-seeker\n"), + ["Bixby", "Reynolds"] + ); + assert_eq!( + names_from_sheet("# Name: Jonathan Bronski\n\n## Slug: The haunted murderer\n"), + ["Jonathan", "Bronski"] + ); + } + + #[test] + fn an_unfilled_template_sheet_yields_no_names() { + let template = "# Name\n\n### Slug\n\nAge:\n\nDescription:\n"; + assert!(names_from_sheet(template).is_empty()); + assert!(names_from_sheet("# Protagonist\n").is_empty()); + assert!(names_from_sheet("no heading at all\n").is_empty()); + } + + #[test] + fn only_the_top_level_heading_is_read() { + // The sheet's own sections must not be mistaken for names. + let sheet = "# Paige Reynolds\n\n### The Indoctrinated Traveller\n\n## Slug: x\n"; + assert_eq!(names_from_sheet(sheet), ["Paige", "Reynolds"]); + } + + #[test] + fn lowercase_and_tiny_words_in_a_name_are_skipped() { + // Particles and initials are not worth listing on their own. + assert_eq!( + names_from_sheet("# Bixby de Reynolds Jr\n"), + ["Bixby", "Reynolds", "Jr"] + ); + } }