dcb97da518
- 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>
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
//! 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
|
|
}
|
|
}
|