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
+86 -16
View File
@@ -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();