Files
md-manuscript/src/app/characters.rs
T
landon 043cc692ac 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
2026-08-24 19:56:36 -05:00

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('\\', "/"))
}
}