Add project-mode tooling: characters, outline, diff and revision status

Rounds out project mode, where the workspace is the project root and one
subfolder holds the manuscript proper:

- Characters and Outline windows, backed by new `characters` and `outline`
  modules that read the cast from character sheets and measure how much of
  the snowflake outline is actually written.
- Edit ▸ Changes… diffs the open file against its last committed version.
- Revision status, per-file and project word counts, an archive action and
  hidden folders in the file panel.
- Chapter-file export alongside the ODT master, richer header parsing, and
  a project word list for names and invented terms.
- The export path now follows the workspace: opening a project points it at
  that project root, keeping a file name you chose yourself and re-deriving
  one that merely echoed the folder it sat in.
- Clicking an issue in the grammar/spelling panel takes the editor to it,
  selecting the words and centring them; applying a suggestion jumps to the
  rewritten text as well.

README covers the new windows and workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
This commit is contained in:
2026-08-24 19:56:36 -05:00
parent e951d57d45
commit 043cc692ac
23 changed files with 3879 additions and 144 deletions
+255 -13
View File
@@ -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<PathBuf> {
/// 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<usize, String> {
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<String> = 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<PathBuf> {
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(&current) 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
}