//! 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::{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. `add_requested` collects the /// directory path a right-click menu's "Add … entry…" was clicked for, /// and `rename_requested` the file path a "Rename / Move…" was clicked /// for, so the caller can open the relevant dialog once rendering is /// done rather than mutating `self` from inside this `&self` walk. fn render_bible_tree( &self, ui: &mut egui::Ui, nodes: &[BibleTreeNode], selected_path: &Option, add_requested: &mut Option, rename_requested: &mut 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); let header = egui::CollapsingHeader::new(name) .default_open(true) .show(ui, |ui| { if let Some(child_clicked) = self.render_bible_tree( ui, children, selected_path, add_requested, rename_requested, ) { clicked_path = Some(child_clicked); } }); header.header_response.context_menu(|ui| { let entry_type = infer_entry_type(path); let label = format!("➕ Add {entry_type} entry…"); if ui.button(label).clicked() { *add_requested = Some(path.clone()); ui.close_menu(); } }); } BibleTreeNode::File { name, path } => { let is_selected = selected_path.as_ref().map_or(false, |p| p == path); let response = ui.selectable_label(is_selected, name); if response.clicked() { clicked_path = Some(path.clone()); } response.context_menu(|ui| { if ui.button("📝 Rename / Move…").clicked() { *rename_requested = Some(path.clone()); ui.close_menu(); } }); } } } 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) // A window only paints as large as its content actually // measures (`default_width`/`default_height` are no more than // hints, silently ignored the moment content isn't a single // top-level ScrollArea), so what actually renders is whatever // size the tree/editor panes below claim for themselves — // that's why they're given explicit heights rather than left to // `auto_shrink(false)` alone, which only stops a pane from // shrinking *below* whatever height it's handed, and doesn't // make it claim more. This is also why the resize corner used // to appear to do nothing: every frame's content re-measure // snapped the drag straight back to that same small size. .min_width(1000.0) .min_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; // Directory a right-click menu's "Add … entry…" was clicked for, if // any — applied after the tree finishes rendering, same reason. let mut add_requested: Option = None; // File path a right-click menu's "Rename / Move…" was clicked for, // if any — same deferred-handling reason as `add_requested`. let mut rename_requested: Option = None; 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. `auto_shrink(false)` only stops // this from shrinking *below* whatever height the // surrounding layout hands it — it doesn't claim more on // its own, and `max_height` is a ceiling, not a floor (it // can only shrink, never grow, what the ambient layout // already offered). `min_scrolled_height` is the one // that's actually enforced unconditionally, which is what // establishes — and drives the window's own measured size // up to — a real, spacious height. egui::ScrollArea::vertical() .id_salt("story_bible_editor_tree") .auto_shrink(false) .min_scrolled_height(600.0) .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, &mut add_requested, &mut rename_requested, ) { // 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(); // The Save/Close row lives in its own panel, claimed *before* // the editor's scroll area: a resizable window fights the // user's own resize drag when a ScrollArea isn't the last // thing measured in its content (the cursor shows the resize // affordance but dragging does nothing) — reserving this // panel's height up front, the same way file_list.rs's // `left_pane` does for its own action row, avoids that. egui::TopBottomPanel::bottom("story_bible_editor_actions") .frame(egui::Frame::none().inner_margin(egui::Margin::symmetric(0.0, 4.0))) .show_inside(ui, |ui| { 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; } }); }); // Editor area — see the tree pane's comment above on why // `min_scrolled_height` (not `max_height`) is needed. egui::ScrollArea::vertical() .id_salt("story_bible_editor_content") .auto_shrink(false) .min_scrolled_height(520.0) .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(); }); }); }); self.story_bible_editor_focused = focused; if let Some(dir) = add_requested { self.add_story_bible_type = infer_entry_type(&dir); self.add_story_bible_dir = dir; self.add_story_bible_name.clear(); self.show_add_story_bible_item = true; self.add_story_bible_focus = true; } if let Some(path) = rename_requested { self.rename_story_bible_input = strip_md(&path).to_string(); self.rename_story_bible_target = Some(path); self.show_rename_story_bible = true; self.rename_story_bible_focus = true; } } /// 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); } } } /// The "Add … entry…" dialog opened from a directory's right-click menu. /// Mirrors `rename_window`'s shape (`src/app/workspace.rs`): a small /// modal with a focused name field, Enter-to-submit, Create/Cancel. pub(super) fn add_story_bible_item_window(&mut self, ctx: &egui::Context) { let mut open = self.show_add_story_bible_item; let mut submit = false; let mut cancel = false; let title = format!("Add {} entry", capitalize(&self.add_story_bible_type)); egui::Window::new(title) .open(&mut open) .resizable(false) .collapsible(false) .default_width(320.0) .show(ctx, |ui| { ui.label( egui::RichText::new(format!("in {}/", self.add_story_bible_dir)) .small() .weak(), ); ui.add_space(4.0); let r = ui.add( egui::TextEdit::singleline(&mut self.add_story_bible_name) .desired_width(f32::INFINITY) .hint_text("e.g. Captain Reyes"), ); if self.add_story_bible_focus { r.request_focus(); self.add_story_bible_focus = false; } submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.label( egui::RichText::new(format!( "Starts with id/name/aliases/summary/type: {} frontmatter — \ edit any of it once it's created.", self.add_story_bible_type )) .small() .weak(), ); ui.separator(); ui.horizontal(|ui| { submit |= ui.button("Create").clicked(); cancel |= ui.button("Cancel").clicked(); }); }); if cancel || !open { self.show_add_story_bible_item = false; return; } if submit { if self.create_story_bible_item() { self.show_add_story_bible_item = false; } else { // Rejected (empty name): keep the dialog up, same as Rename. self.add_story_bible_focus = true; } } } /// Create the new Story Bible entry `add_story_bible_name` names, inside /// `add_story_bible_dir`, and load it into the editor. Returns `false` /// (leaving the dialog open) when there's no usable name yet. fn create_story_bible_item(&mut self) -> bool { let name = self.add_story_bible_name.trim(); if name.is_empty() { self.status = "Name needed".to_string(); return false; } let Some(base_dir) = self.find_story_bible_directory() else { self.status = "Story Bible directory not found".to_string(); return false; }; let dir = base_dir.join(&self.add_story_bible_dir); if let Err(e) = fs::create_dir_all(&dir) { self.status = format!("Could not create {}: {e}", dir.display()); return false; } let stem = unique_slug(&dir, &slugify(name)); let rel_path = format!("{}/{stem}.md", self.add_story_bible_dir); let full_path = base_dir.join(&rel_path); let content = format!( "---\n\ id: {stem}\n\ name: \"{name}\"\n\ aliases: []\n\ summary: \"\"\n\ type: {}\n\ status: active\n\ ---\n\n\ # {name}\n", self.add_story_bible_type, ); match fs::write(&full_path, &content) { Ok(()) => { self.status = format!("Created {rel_path}"); self.story_bible_editor_content = content; self.story_bible_editor_path = Some(rel_path); self.build_story_bible_index(); true } Err(e) => { self.status = format!("Could not create {rel_path}: {e}"); false } } } /// The rename/move dialog opened from a file row's context menu. Renaming /// and moving are the same operation here, same as the manuscript file /// panel's `rename_window`: editing the leaf renames, editing the folder /// part moves. pub(super) fn rename_story_bible_item_window(&mut self, ctx: &egui::Context) { if self.rename_story_bible_target.is_none() { // The tree changed underneath us; nothing left to rename. self.show_rename_story_bible = false; return; } let mut open = self.show_rename_story_bible; let mut submit = false; let mut cancel = false; egui::Window::new("Rename / Move Story Bible item") .open(&mut open) .resizable(false) .collapsible(false) .default_width(320.0) .show(ctx, |ui| { if let Some(ref current) = self.rename_story_bible_target { ui.label(egui::RichText::new(current.as_str()).small().weak()); } ui.add_space(4.0); let r = ui.add( egui::TextEdit::singleline(&mut self.rename_story_bible_input) .desired_width(f32::INFINITY) .hint_text("name, or a path to move it"), ); if self.rename_story_bible_focus { r.request_focus(); self.rename_story_bible_focus = false; } submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.label( egui::RichText::new( "The path within the Story Bible folder — edit the folder part \ to move it. The .md extension is added for you.", ) .small() .weak(), ); ui.separator(); ui.horizontal(|ui| { submit |= ui.button("Rename").clicked(); cancel |= ui.button("Cancel").clicked(); }); }); if cancel || !open { self.show_rename_story_bible = false; return; } if submit { if self.rename_selected_story_bible_item() { self.show_rename_story_bible = false; } else { self.rename_story_bible_focus = true; } } } /// Move/rename the Story Bible item named by `rename_story_bible_target` /// to `rename_story_bible_input`. Returns `false` (leaving the dialog /// open) when the input isn't usable or the destination is taken. fn rename_selected_story_bible_item(&mut self) -> bool { let Some(old_path) = self.rename_story_bible_target.clone() else { return false; }; let Some(new_path) = sanitize_rel_path(&self.rename_story_bible_input) else { self.status = "Enter a new name".to_string(); return false; }; if new_path == old_path { // Already what was asked for. self.rename_story_bible_target = None; return true; } let Some(base_dir) = self.find_story_bible_directory() else { self.status = "Story Bible directory not found".to_string(); return false; }; if base_dir.join(&new_path).exists() { self.status = format!("{new_path} already exists"); return false; } match workspace::move_manuscript_file(&base_dir, &old_path, &new_path) { Ok(()) => { if self.story_bible_editor_path.as_deref() == Some(old_path.as_str()) { self.story_bible_editor_path = Some(new_path.clone()); } self.build_story_bible_index(); self.status = format!("Renamed to {new_path}"); self.rename_story_bible_target = None; true } Err(e) => { self.status = format!("Rename failed: {e}"); false } } } /// 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 } } /// Best-guess entry `type:` for a new Story Bible item, from the directory /// it's being added to: the last path component, a leading `NN-`/`NN_`/`NN ` /// ordering prefix dropped, underscores turned to spaces, lowercased, and /// singularized by a simple suffix heuristic (`cities` -> `city`, `Objects` /// -> `object`). Only a default — nothing stops the file's own `type:` /// field being edited afterward if the guess is off. fn infer_entry_type(dir_path: &str) -> String { let last = dir_path.rsplit('/').next().unwrap_or(dir_path).trim(); let rest = last.trim_start_matches(|c: char| c.is_ascii_digit()); let stripped = match rest.strip_prefix(['-', '_', ' ']) { Some(after) if !after.trim().is_empty() => after.trim(), _ => last, }; let lower = stripped.replace(['_', '-'], " ").to_lowercase(); match lower.strip_suffix("ies") { Some(stem) => format!("{stem}y"), None => match lower.strip_suffix('s') { Some(stem) => stem.to_string(), None => lower, }, } } /// Capitalize the first character, leaving the rest alone. fn capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(c) => c.to_uppercase().collect::() + chars.as_str(), None => String::new(), } } /// Turn a typed display name into a filename stem: lowercase, runs of /// anything that isn't a letter or digit collapsed to a single `_`, trimmed /// of leading/trailing `_`. Falls back to `entry` if nothing alphanumeric /// survives (an emoji-only name, say), so a file always gets created. fn slugify(name: &str) -> String { let mut out = String::with_capacity(name.len()); let mut last_was_sep = false; for c in name.chars() { if c.is_alphanumeric() { out.extend(c.to_lowercase()); last_was_sep = false; } else if !last_was_sep && !out.is_empty() { out.push('_'); last_was_sep = true; } } while out.ends_with('_') { out.pop(); } if out.is_empty() { "entry".to_string() } else { out } } /// `slug`, or `slug_2`/`slug_3`/… if a file by that name already exists in /// `dir` — the same "find the first free name" shape as the main file /// panel's `+ New from template`. fn unique_slug(dir: &Path, slug: &str) -> String { if !dir.join(format!("{slug}.md")).exists() { return slug.to_string(); } let mut n = 2; loop { let candidate = format!("{slug}_{n}"); if !dir.join(format!("{candidate}.md")).exists() { return candidate; } n += 1; } } #[cfg(test)] mod tests { use super::*; #[test] fn infers_type_from_a_top_level_category() { assert_eq!(infer_entry_type("01-Characters"), "character"); assert_eq!(infer_entry_type("04-Objects"), "object"); assert_eq!(infer_entry_type("05-Relationships"), "relationship"); } #[test] fn infers_type_from_the_closest_subfolder() { assert_eq!(infer_entry_type("01-Characters/main_characters"), "main character"); assert_eq!(infer_entry_type("02-Locations/cities"), "city"); assert_eq!(infer_entry_type("03-Lore/factions"), "faction"); } #[test] fn a_name_with_no_trailing_s_is_left_alone() { assert_eq!(infer_entry_type("06-Timeline"), "timeline"); assert_eq!(infer_entry_type("03-Lore/history"), "history"); } #[test] fn capitalize_only_touches_the_first_character() { assert_eq!(capitalize("character"), "Character"); assert_eq!(capitalize(""), ""); assert_eq!(capitalize("main character"), "Main character"); } #[test] fn slugify_collapses_punctuation_and_spaces() { assert_eq!(slugify("Captain Reyes"), "captain_reyes"); assert_eq!(slugify(" Mira Voss "), "mira_voss"); assert_eq!(slugify("O'Brien's Tavern!"), "o_brien_s_tavern"); } #[test] fn slugify_falls_back_rather_than_producing_an_empty_name() { assert_eq!(slugify("***"), "entry"); assert_eq!(slugify(""), "entry"); } #[test] fn unique_slug_finds_the_first_free_name() { let dir = std::env::temp_dir().join(format!( "md-manuscript-sbe-slug-test-{}", std::process::id() )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); assert_eq!(unique_slug(&dir, "mira"), "mira"); fs::write(dir.join("mira.md"), "").unwrap(); assert_eq!(unique_slug(&dir, "mira"), "mira_2"); fs::write(dir.join("mira_2.md"), "").unwrap(); assert_eq!(unique_slug(&dir, "mira"), "mira_3"); let _ = fs::remove_dir_all(&dir); } }