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:
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user