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:
+255
-13
@@ -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(¤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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user