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:
@@ -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<String> {
|
||||
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<String> = 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<String> {
|
||||
path.strip_prefix(self.workspace())
|
||||
.ok()?
|
||||
.to_str()
|
||||
.map(|s| s.replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user