043cc692ac
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
201 lines
8.0 KiB
Rust
201 lines
8.0 KiB
Rust
//! 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('\\', "/"))
|
|
}
|
|
}
|