Let the Story Bible Editor create new entries, not just edit existing ones
Right-click a folder in the tree (a top-level category like 01-Characters, or any subfolder like cities) for "Add <type> entry…" — the type is guessed from the folder's own name (singularized: cities -> city, Objects -> object), editable afterward like any other field. Names a file, writes minimal id/name/aliases/summary/type/status frontmatter, and loads it straight into the editor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A tree node for the Story Bible tree view.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -159,12 +159,16 @@ impl App {
|
||||
None
|
||||
}
|
||||
|
||||
/// Render the Story Bible tree recursively.
|
||||
/// Render the Story Bible tree recursively. `add_requested` collects the
|
||||
/// directory path a right-click menu's "Add … entry…" was clicked for,
|
||||
/// so the caller can open the add dialog once rendering is done rather
|
||||
/// than mutating `self` from inside this `&self` walk.
|
||||
fn render_bible_tree(
|
||||
&self,
|
||||
ui: &mut egui::Ui,
|
||||
nodes: &[BibleTreeNode],
|
||||
selected_path: &Option<String>,
|
||||
add_requested: &mut Option<String>,
|
||||
) -> Option<String> {
|
||||
let mut clicked_path: Option<String> = None;
|
||||
|
||||
@@ -172,18 +176,28 @@ impl App {
|
||||
match node {
|
||||
BibleTreeNode::Directory { name, path, children } => {
|
||||
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
|
||||
|
||||
egui::CollapsingHeader::new(name)
|
||||
|
||||
let header = egui::CollapsingHeader::new(name)
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
if let Some(child_clicked) = self.render_bible_tree(ui, children, selected_path) {
|
||||
if let Some(child_clicked) =
|
||||
self.render_bible_tree(ui, children, selected_path, add_requested)
|
||||
{
|
||||
clicked_path = Some(child_clicked);
|
||||
}
|
||||
});
|
||||
header.header_response.context_menu(|ui| {
|
||||
let entry_type = infer_entry_type(path);
|
||||
let label = format!("➕ Add {entry_type} entry…");
|
||||
if ui.button(label).clicked() {
|
||||
*add_requested = Some(path.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
}
|
||||
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()
|
||||
@@ -243,6 +257,9 @@ impl App {
|
||||
// Set by the editor's text area below, read back out once the closures
|
||||
// that borrow `self` have finished.
|
||||
let mut focused = false;
|
||||
// Directory a right-click menu's "Add … entry…" was clicked for, if
|
||||
// any — applied after the tree finishes rendering, same reason.
|
||||
let mut add_requested: Option<String> = None;
|
||||
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 —
|
||||
@@ -276,7 +293,7 @@ impl App {
|
||||
} else {
|
||||
// Render tree
|
||||
if let Some(clicked_path) = self.render_bible_tree(
|
||||
ui, tree, &self.story_bible_editor_path
|
||||
ui, tree, &self.story_bible_editor_path, &mut add_requested,
|
||||
) {
|
||||
// Load the clicked file
|
||||
self.load_story_bible_item(&clicked_path);
|
||||
@@ -345,6 +362,14 @@ impl App {
|
||||
});
|
||||
});
|
||||
self.story_bible_editor_focused = focused;
|
||||
|
||||
if let Some(dir) = add_requested {
|
||||
self.add_story_bible_type = infer_entry_type(&dir);
|
||||
self.add_story_bible_dir = dir;
|
||||
self.add_story_bible_name.clear();
|
||||
self.show_add_story_bible_item = true;
|
||||
self.add_story_bible_focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a Story Bible item from the workspace.
|
||||
@@ -390,6 +415,116 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Add … entry…" dialog opened from a directory's right-click menu.
|
||||
/// Mirrors `rename_window`'s shape (`src/app/workspace.rs`): a small
|
||||
/// modal with a focused name field, Enter-to-submit, Create/Cancel.
|
||||
pub(super) fn add_story_bible_item_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_add_story_bible_item;
|
||||
let mut submit = false;
|
||||
let mut cancel = false;
|
||||
let title = format!("Add {} entry", capitalize(&self.add_story_bible_type));
|
||||
egui::Window::new(title)
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.default_width(320.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("in {}/", self.add_story_bible_dir))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.add_story_bible_name)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text("e.g. Captain Reyes"),
|
||||
);
|
||||
if self.add_story_bible_focus {
|
||||
r.request_focus();
|
||||
self.add_story_bible_focus = false;
|
||||
}
|
||||
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Starts with id/name/aliases/summary/type: {} frontmatter — \
|
||||
edit any of it once it's created.",
|
||||
self.add_story_bible_type
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
submit |= ui.button("Create").clicked();
|
||||
cancel |= ui.button("Cancel").clicked();
|
||||
});
|
||||
});
|
||||
if cancel || !open {
|
||||
self.show_add_story_bible_item = false;
|
||||
return;
|
||||
}
|
||||
if submit {
|
||||
if self.create_story_bible_item() {
|
||||
self.show_add_story_bible_item = false;
|
||||
} else {
|
||||
// Rejected (empty name): keep the dialog up, same as Rename.
|
||||
self.add_story_bible_focus = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the new Story Bible entry `add_story_bible_name` names, inside
|
||||
/// `add_story_bible_dir`, and load it into the editor. Returns `false`
|
||||
/// (leaving the dialog open) when there's no usable name yet.
|
||||
fn create_story_bible_item(&mut self) -> bool {
|
||||
let name = self.add_story_bible_name.trim();
|
||||
if name.is_empty() {
|
||||
self.status = "Name needed".to_string();
|
||||
return false;
|
||||
}
|
||||
let Some(base_dir) = self.find_story_bible_directory() else {
|
||||
self.status = "Story Bible directory not found".to_string();
|
||||
return false;
|
||||
};
|
||||
let dir = base_dir.join(&self.add_story_bible_dir);
|
||||
if let Err(e) = fs::create_dir_all(&dir) {
|
||||
self.status = format!("Could not create {}: {e}", dir.display());
|
||||
return false;
|
||||
}
|
||||
|
||||
let stem = unique_slug(&dir, &slugify(name));
|
||||
let rel_path = format!("{}/{stem}.md", self.add_story_bible_dir);
|
||||
let full_path = base_dir.join(&rel_path);
|
||||
|
||||
let content = format!(
|
||||
"---\n\
|
||||
id: {stem}\n\
|
||||
name: \"{name}\"\n\
|
||||
aliases: []\n\
|
||||
summary: \"\"\n\
|
||||
type: {}\n\
|
||||
status: active\n\
|
||||
---\n\n\
|
||||
# {name}\n",
|
||||
self.add_story_bible_type,
|
||||
);
|
||||
|
||||
match fs::write(&full_path, &content) {
|
||||
Ok(()) => {
|
||||
self.status = format!("Created {rel_path}");
|
||||
self.story_bible_editor_content = content;
|
||||
self.story_bible_editor_path = Some(rel_path);
|
||||
self.build_story_bible_index();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.status = format!("Could not create {rel_path}: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Story Bible editor.
|
||||
pub(super) fn open_story_bible_editor(&mut self) {
|
||||
self.show_story_bible_editor = true;
|
||||
@@ -420,3 +555,142 @@ impl App {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-guess entry `type:` for a new Story Bible item, from the directory
|
||||
/// it's being added to: the last path component, a leading `NN-`/`NN_`/`NN `
|
||||
/// ordering prefix dropped, underscores turned to spaces, lowercased, and
|
||||
/// singularized by a simple suffix heuristic (`cities` -> `city`, `Objects`
|
||||
/// -> `object`). Only a default — nothing stops the file's own `type:`
|
||||
/// field being edited afterward if the guess is off.
|
||||
fn infer_entry_type(dir_path: &str) -> String {
|
||||
let last = dir_path.rsplit('/').next().unwrap_or(dir_path).trim();
|
||||
let rest = last.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
let stripped = match rest.strip_prefix(['-', '_', ' ']) {
|
||||
Some(after) if !after.trim().is_empty() => after.trim(),
|
||||
_ => last,
|
||||
};
|
||||
let lower = stripped.replace(['_', '-'], " ").to_lowercase();
|
||||
match lower.strip_suffix("ies") {
|
||||
Some(stem) => format!("{stem}y"),
|
||||
None => match lower.strip_suffix('s') {
|
||||
Some(stem) => stem.to_string(),
|
||||
None => lower,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Capitalize the first character, leaving the rest alone.
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a typed display name into a filename stem: lowercase, runs of
|
||||
/// anything that isn't a letter or digit collapsed to a single `_`, trimmed
|
||||
/// of leading/trailing `_`. Falls back to `entry` if nothing alphanumeric
|
||||
/// survives (an emoji-only name, say), so a file always gets created.
|
||||
fn slugify(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut last_was_sep = false;
|
||||
for c in name.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
out.extend(c.to_lowercase());
|
||||
last_was_sep = false;
|
||||
} else if !last_was_sep && !out.is_empty() {
|
||||
out.push('_');
|
||||
last_was_sep = true;
|
||||
}
|
||||
}
|
||||
while out.ends_with('_') {
|
||||
out.pop();
|
||||
}
|
||||
if out.is_empty() {
|
||||
"entry".to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `slug`, or `slug_2`/`slug_3`/… if a file by that name already exists in
|
||||
/// `dir` — the same "find the first free name" shape as the main file
|
||||
/// panel's `+ New from template`.
|
||||
fn unique_slug(dir: &Path, slug: &str) -> String {
|
||||
if !dir.join(format!("{slug}.md")).exists() {
|
||||
return slug.to_string();
|
||||
}
|
||||
let mut n = 2;
|
||||
loop {
|
||||
let candidate = format!("{slug}_{n}");
|
||||
if !dir.join(format!("{candidate}.md")).exists() {
|
||||
return candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn infers_type_from_a_top_level_category() {
|
||||
assert_eq!(infer_entry_type("01-Characters"), "character");
|
||||
assert_eq!(infer_entry_type("04-Objects"), "object");
|
||||
assert_eq!(infer_entry_type("05-Relationships"), "relationship");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_type_from_the_closest_subfolder() {
|
||||
assert_eq!(infer_entry_type("01-Characters/main_characters"), "main character");
|
||||
assert_eq!(infer_entry_type("02-Locations/cities"), "city");
|
||||
assert_eq!(infer_entry_type("03-Lore/factions"), "faction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_no_trailing_s_is_left_alone() {
|
||||
assert_eq!(infer_entry_type("06-Timeline"), "timeline");
|
||||
assert_eq!(infer_entry_type("03-Lore/history"), "history");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_only_touches_the_first_character() {
|
||||
assert_eq!(capitalize("character"), "Character");
|
||||
assert_eq!(capitalize(""), "");
|
||||
assert_eq!(capitalize("main character"), "Main character");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_collapses_punctuation_and_spaces() {
|
||||
assert_eq!(slugify("Captain Reyes"), "captain_reyes");
|
||||
assert_eq!(slugify(" Mira Voss "), "mira_voss");
|
||||
assert_eq!(slugify("O'Brien's Tavern!"), "o_brien_s_tavern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_falls_back_rather_than_producing_an_empty_name() {
|
||||
assert_eq!(slugify("***"), "entry");
|
||||
assert_eq!(slugify(""), "entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_slug_finds_the_first_free_name() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"md-manuscript-sbe-slug-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
assert_eq!(unique_slug(&dir, "mira"), "mira");
|
||||
|
||||
fs::write(dir.join("mira.md"), "").unwrap();
|
||||
assert_eq!(unique_slug(&dir, "mira"), "mira_2");
|
||||
|
||||
fs::write(dir.join("mira_2.md"), "").unwrap();
|
||||
assert_eq!(unique_slug(&dir, "mira"), "mira_3");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user