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
+121 -2
View File
@@ -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).