Files
md-manuscript/src/app/story_bible_editor.rs
T
landon 0da52c3054 Fix Story Bible Editor's content panel rendering a sliver wide
ui.set_width() was called on the shared horizontal-layout Ui before the
tree and editor panels were split off, so it shrank the whole row —
tree, separator, and editor panel together — down to a third of the
window's width instead of just the tree column. The editor's own
heading (the open item's path) was narrow enough to wrap one letter
per line as a result. Moving the width constraint onto the tree
panel's own child Ui, where it belongs, leaves the editor panel free
to take the rest of the row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:10:02 -05:00

389 lines
15 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;
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;
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
egui::ScrollArea::vertical()
.id_salt("story_bible_editor_tree")
.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()
.id_salt("story_bible_editor_content")
.auto_shrink(false)
.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();
});
// 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;
}
});
});
});
self.story_bible_editor_focused = focused;
}
/// 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
}
}