//! 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 { 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 = 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 { path.strip_prefix(self.workspace()) .ok()? .to_str() .map(|s| s.replace('\\', "/")) } }