feat: Add Story Bible breakout editor window

- Add new module src/app/story_bible_editor.rs for dedicated Story Bible editing
- Add 'Edit -> Story Bible' menu option to open the editor
- Tree view (left 1/3) shows Story Bible directory structure with collapsible folders
- Editor (right 2/3) allows editing selected Story Bible items
- Auto-load first file on open, support Ctrl+S to save
- Rebuild Story Bible index after saving to pick up changes
- Update README.md with Story Bible Editor documentation

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
landon
2026-09-16 11:21:15 -05:00
parent 55bf006216
commit dcb97da518
4 changed files with 409 additions and 1 deletions
+15
View File
@@ -391,6 +391,21 @@ markdown file with YAML frontmatter containing `id:` and `title:` fields will be
included in the index. References in your draft text that match Story Bible items included in the index. References in your draft text that match Story Bible items
are highlighted and clickable. are highlighted and clickable.
### Story Bible Editor (Edit ▸ Story Bible…)
For a dedicated editing experience, use the **Story Bible breakout editor**. This
provides a focused workspace for managing your Story Bible items:
* **Tree view** (left 1/3 of window): Shows the complete Story Bible directory structure
with collapsible folders for Characters, Locations, Lore, and other categories
* **Editor** (right 2/3 of window): Full-featured text editor for the selected item
* **Auto-load**: Select any file from the tree to load it into the editor
* **Save**: Click **Save** or press `Ctrl+S` to write changes back to disk
* **Index updates**: Saving a file automatically rebuilds the Story Bible index
The editor is ideal for extended Story Bible work, allowing you to browse and edit
items without leaving the context of your reference material.
### Outline (Tools ▸ Outline…) ### Outline (Tools ▸ Outline…)
The snowflake outline files are scaffolds of HTML comments, one per beat The snowflake outline files are scaffolds of HTML comments, one per beat
+15 -1
View File
@@ -23,10 +23,11 @@ mod diff;
mod editor; mod editor;
mod file_list; mod file_list;
mod find; mod find;
mod grammar;
mod outline; mod outline;
mod project; mod project;
mod grammar;
mod spelling; mod spelling;
mod story_bible_editor;
mod story_bible_ui; mod story_bible_ui;
mod style; mod style;
mod ui; mod ui;
@@ -381,6 +382,12 @@ pub struct App {
selected_bible_item: Option<String>, selected_bible_item: Option<String>,
/// References found in the current draft file. /// References found in the current draft file.
current_references: Vec<crate::story_bible::StoryBibleReference>, current_references: Vec<crate::story_bible::StoryBibleReference>,
/// Whether the Story Bible breakout editor window is open.
show_story_bible_editor: bool,
/// The content being edited in the Story Bible editor.
story_bible_editor_content: String,
/// The workspace-relative path of the file being edited in the Story Bible editor.
story_bible_editor_path: Option<String>,
} }
impl App { impl App {
@@ -485,6 +492,9 @@ impl App {
show_story_bible: false, show_story_bible: false,
selected_bible_item: None, selected_bible_item: None,
current_references: Vec::new(), current_references: Vec::new(),
show_story_bible_editor: false,
story_bible_editor_content: String::new(),
story_bible_editor_path: None,
}; };
app.load_spell_dict(); app.load_spell_dict();
app.open_workspace(); app.open_workspace();
@@ -611,6 +621,10 @@ impl eframe::App for App {
self.story_bible_window(ctx); self.story_bible_window(ctx);
} }
if self.show_story_bible_editor {
self.story_bible_editor_window(ctx);
}
if self.show_manuscript_settings { if self.show_manuscript_settings {
self.manuscript_settings_window(ctx); self.manuscript_settings_window(ctx);
} }
+375
View File
@@ -0,0 +1,375 @@
//! 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<BibleTreeNode>,
},
/// A file/node (leaf)
File {
name: String,
path: String,
},
}
impl BibleTreeNode {
/// Create a directory node.
fn dir(name: String, path: String, children: Vec<BibleTreeNode>) -> 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<BibleTreeNode> {
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<BibleTreeNode> {
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<PathBuf> {
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<String>,
) -> Option<String> {
let mut clicked_path: Option<String> = 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;
}
}
/// Contents of the Story Bible editor window.
fn story_bible_editor_contents(&mut self, ui: &mut egui::Ui, tree: &[BibleTreeNode]) {
ui.horizontal(|ui| {
// Left panel: Tree view (1/3 width)
let tree_width = ui.available_width() / 3.0;
ui.set_width(tree_width);
ui.vertical(|ui| {
ui.heading("Story Bible");
ui.separator();
// Scroll area for tree
egui::ScrollArea::vertical()
.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()
.auto_shrink(false)
.show(ui, |ui| {
let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20);
let response = ui.add(text_edit);
// Handle keyboard shortcuts
if response.has_focus() {
// Ctrl+S to save - we'll handle this differently
// since we can't mutate self directly from within the closure
}
});
// 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;
}
});
});
});
}
/// 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<String> {
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
}
}
+4
View File
@@ -321,6 +321,10 @@ impl App {
ui.close_menu(); ui.close_menu();
self.open_find(true); self.open_find(true);
} }
if ui.button("📖 Story Bible…").clicked() {
ui.close_menu();
self.open_story_bible_editor();
}
}); });
ui.menu_button("Tools", |ui| { ui.menu_button("Tools", |ui| {
if ui.button("☑ Outline…").clicked() { if ui.button("☑ Outline…").clicked() {