//! Story Bible breakout editor: a dedicated window for editing Story Bible items. //! //! Provides a tree view of Story Bible items on the left and an editor on the right. use super::*; use std::fs; use std::path::PathBuf; /// A tree node for the Story Bible tree view. #[derive(Debug, Clone)] pub enum BibleTreeNode { /// A directory/folder node with children Directory { name: String, path: String, children: Vec, }, /// A file/node (leaf) File { name: String, path: String, }, } impl BibleTreeNode { /// Create a directory node. fn dir(name: String, path: String, children: Vec) -> Self { Self::Directory { name, path, children } } /// Create a file node. fn file(name: String, path: String) -> Self { Self::File { name, path } } } impl App { /// Build the Story Bible tree structure from the Story Bible directory. pub(super) fn build_story_bible_tree(&self) -> Vec { let story_bible_dir = self.find_story_bible_directory(); let Some(base_dir) = story_bible_dir else { return Vec::new(); }; let mut root_nodes = Vec::new(); // Read the directory structure if let Ok(entries) = fs::read_dir(&base_dir) { let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); // Sort entries: directories first, then files, both alphabetically entries.sort_by(|a, b| { let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir()); let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir()); if a_is_dir != b_is_dir { b_is_dir.cmp(&a_is_dir) // dirs first } else { a.file_name().cmp(&b.file_name()) } }); for entry in entries { let path = entry.path(); let file_name = entry.file_name().to_string_lossy().to_string(); // Skip hidden files/directories if file_name.starts_with('.') { continue; } let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir()); if is_dir { // Recursively build subtree let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string(); let display_name = if rel_path.is_empty() { base_dir.file_name().unwrap_or_else(|| "Story Bible".as_ref()).to_string_lossy().to_string() } else { path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string() }; let children = Self::build_tree_recursive(&path, &base_dir); root_nodes.push(BibleTreeNode::dir(display_name, rel_path, children)); } else if path.extension().and_then(|s| s.to_str()) == Some("md") { // It's a markdown file let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string(); let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string(); root_nodes.push(BibleTreeNode::file(display_name, rel_path)); } } } root_nodes } /// Recursively build tree from a directory. fn build_tree_recursive(dir_path: &PathBuf, base_dir: &PathBuf) -> Vec { let mut children = Vec::new(); if let Ok(entries) = fs::read_dir(dir_path) { let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); // Sort entries: directories first, then files, both alphabetically entries.sort_by(|a, b| { let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir()); let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir()); if a_is_dir != b_is_dir { b_is_dir.cmp(&a_is_dir) // dirs first } else { a.file_name().cmp(&b.file_name()) } }); for entry in entries { let path = entry.path(); let file_name = entry.file_name().to_string_lossy().to_string(); // Skip hidden files/directories if file_name.starts_with('.') { continue; } let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir()); if is_dir { let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string(); let display_name = path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string(); let grandchildren = Self::build_tree_recursive(&path, base_dir); children.push(BibleTreeNode::dir(display_name, rel_path, grandchildren)); } else if path.extension().and_then(|s| s.to_str()) == Some("md") { let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string(); let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string(); children.push(BibleTreeNode::file(display_name, rel_path)); } } } children } /// Find the Story Bible directory path. pub(super) fn find_story_bible_directory(&self) -> Option { use std::path::Path; let ws = self.config.workspace.clone(); // Look for Story Bible directory - common locations let story_bible_dirs = [ "03-Story-Bible", "03-Characters", "02-Story Bible", "Story Bible", "Characters", ]; for dir_name in story_bible_dirs.iter() { let dir_path = Path::new(&ws).join(dir_name); if dir_path.exists() && dir_path.is_dir() { return Some(dir_path.to_path_buf()); } } None } /// Render the Story Bible tree recursively. fn render_bible_tree( &self, ui: &mut egui::Ui, nodes: &[BibleTreeNode], selected_path: &Option, ) -> Option { let mut clicked_path: Option = None; for node in nodes { match node { BibleTreeNode::Directory { name, path, children } => { let _is_selected = selected_path.as_ref().map_or(false, |p| p == path); egui::CollapsingHeader::new(name) .default_open(true) .show(ui, |ui| { if let Some(child_clicked) = self.render_bible_tree(ui, children, selected_path) { clicked_path = Some(child_clicked); } }); } BibleTreeNode::File { name, path } => { let is_selected = selected_path.as_ref().map_or(false, |p| p == path); if ui .selectable_label(is_selected, name) .clicked() { clicked_path = Some(path.clone()); } } } } clicked_path } /// Show the Story Bible breakout editor window. pub(super) fn story_bible_editor_window(&mut self, ctx: &egui::Context) { // Build tree structure let tree = self.build_story_bible_tree(); // Create a window with custom sizing - we want tree on left 1/3, editor on right 2/3 let mut open = self.show_story_bible_editor; egui::Window::new("Story Bible Editor") .open(&mut open) .resizable(true) .collapsible(false) .default_width(1000.0) .default_height(700.0) .show(ctx, |ui| { self.story_bible_editor_contents(ui, &tree); }); self.show_story_bible_editor = open; // Clear editor state when window is closed if !open { self.story_bible_editor_content.clear(); self.story_bible_editor_path = None; self.story_bible_editor_focused = false; } } /// Contents of the Story Bible editor window. fn story_bible_editor_contents(&mut self, ui: &mut egui::Ui, tree: &[BibleTreeNode]) { // Set by the editor's text area below, read back out once the closures // that borrow `self` have finished. let mut focused = false; ui.horizontal(|ui| { // Left panel: Tree view (1/3 width). The width must be set on // this panel's own child `Ui`, not the shared horizontal one — // that had been shrinking the whole row (tree, separator, and // the editor panel with it) down to a third of the window, // which is why the editor's heading used to wrap one letter at // a time. let tree_width = ui.available_width() / 3.0; ui.vertical(|ui| { ui.set_width(tree_width); ui.heading("Story Bible"); ui.separator(); // Scroll area for tree egui::ScrollArea::vertical() .id_salt("story_bible_editor_tree") .auto_shrink(false) .show(ui, |ui| { if tree.is_empty() { ui.label("No Story Bible items found"); } else { // Render tree if let Some(clicked_path) = self.render_bible_tree( ui, tree, &self.story_bible_editor_path ) { // Load the clicked file self.load_story_bible_item(&clicked_path); } } }); }); ui.separator(); // Right panel: Editor (2/3 width) ui.vertical(|ui| { // Show current file info if let Some(ref path) = self.story_bible_editor_path { ui.heading(path); } else { ui.heading("Select an item to edit"); } ui.separator(); // Editor area egui::ScrollArea::vertical() .id_salt("story_bible_editor_content") .auto_shrink(false) .show(ui, |ui| { let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content) .id_salt("story_bible_editor_textedit") .code_editor() .desired_width(f32::INFINITY) .desired_rows(20); let response = ui.add(text_edit); // Recorded rather than acted on: the save needs `&mut // self`, which this closure is already borrowing to // draw with. `App::update` reads the flag next frame and // routes Ctrl+S here instead of to the manuscript. focused = response.has_focus(); }); // Save button ui.separator(); ui.horizontal(|ui| { if ui.button("Save (Ctrl+S)").clicked() { if let Some(ref path) = self.story_bible_editor_path { self.save_story_bible_item(path.clone()); } } if ui.button("Close").clicked() { self.show_story_bible_editor = false; } }); }); }); self.story_bible_editor_focused = focused; } /// Load a Story Bible item from the workspace. pub(super) fn load_story_bible_item(&mut self, workspace_rel_path: &str) { let story_bible_dir = self.find_story_bible_directory(); let Some(base_dir) = story_bible_dir else { self.status = "Story Bible directory not found".to_string(); return; }; let full_path = base_dir.join(workspace_rel_path); match fs::read_to_string(&full_path) { Ok(content) => { self.story_bible_editor_content = content; self.story_bible_editor_path = Some(workspace_rel_path.to_string()); } Err(e) => { self.status = format!("Could not read {}: {}", workspace_rel_path, e); } } } /// Save the current Story Bible item. pub(super) fn save_story_bible_item(&mut self, workspace_rel_path: String) { let story_bible_dir = self.find_story_bible_directory(); let Some(base_dir) = story_bible_dir else { self.status = "Story Bible directory not found".to_string(); return; }; let full_path = base_dir.join(&workspace_rel_path); match fs::write(&full_path, &self.story_bible_editor_content) { Ok(_) => { self.status = format!("Saved: {}", workspace_rel_path); // Rebuild the Story Bible index to pick up any changes self.build_story_bible_index(); } Err(e) => { self.status = format!("Could not save {}: {}", workspace_rel_path, e); } } } /// Open the Story Bible editor. pub(super) fn open_story_bible_editor(&mut self) { self.show_story_bible_editor = true; // If we have a tree, try to select the first item let tree = self.build_story_bible_tree(); if !tree.is_empty() { if let Some(first_file) = Self::find_first_file(&tree) { self.load_story_bible_item(&first_file); } } } /// Find the first file in the tree (depth-first). fn find_first_file(nodes: &[BibleTreeNode]) -> Option { for node in nodes { match node { BibleTreeNode::Directory { children, .. } => { if let Some(file) = Self::find_first_file(children) { return Some(file); } } BibleTreeNode::File { path, .. } => { return Some(path.clone()); } } } None } }