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:
+15
-1
@@ -23,10 +23,11 @@ mod diff;
|
||||
mod editor;
|
||||
mod file_list;
|
||||
mod find;
|
||||
mod grammar;
|
||||
mod outline;
|
||||
mod project;
|
||||
mod grammar;
|
||||
mod spelling;
|
||||
mod story_bible_editor;
|
||||
mod story_bible_ui;
|
||||
mod style;
|
||||
mod ui;
|
||||
@@ -381,6 +382,12 @@ pub struct App {
|
||||
selected_bible_item: Option<String>,
|
||||
/// References found in the current draft file.
|
||||
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 {
|
||||
@@ -485,6 +492,9 @@ impl App {
|
||||
show_story_bible: false,
|
||||
selected_bible_item: None,
|
||||
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.open_workspace();
|
||||
@@ -611,6 +621,10 @@ impl eframe::App for App {
|
||||
self.story_bible_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_story_bible_editor {
|
||||
self.story_bible_editor_window(ctx);
|
||||
}
|
||||
|
||||
if self.show_manuscript_settings {
|
||||
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();
|
||||
self.open_find(true);
|
||||
}
|
||||
if ui.button("📖 Story Bible…").clicked() {
|
||||
ui.close_menu();
|
||||
self.open_story_bible_editor();
|
||||
}
|
||||
});
|
||||
ui.menu_button("Tools", |ui| {
|
||||
if ui.button("☑ Outline…").clicked() {
|
||||
|
||||
Reference in New Issue
Block a user