feat: Implement bidirectional Story Bible links with draft text

- Add story_bible.rs module with StoryBibleIndex, StoryBibleItem, and
  StoryBibleReference types for indexing Story Bible content
- Support wiki-style [[...]] and mention-style @... syntax for references
- Highlight Story Bible references in editor with purple background
- Add Story Bible window (View > Tools > Story Bible) to browse items
- Show references from current draft file in Story Bible window
- Allow clicking references to jump to their location in the editor
- Auto-index Story Bible items from common directories (03-Characters,
  02-Story Bible, Characters, etc.)
- Extract metadata from YAML frontmatter or filenames
- Add walkdir dependency for directory scanning
- Fix pre-existing format string errors in workspace.rs

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
landon
2026-09-16 08:56:28 -05:00
parent 511a7588fa
commit ca3e476a14
9 changed files with 631 additions and 156 deletions
+41 -1
View File
@@ -43,6 +43,13 @@ impl App {
.id_salt("editor")
.auto_shrink([false, false])
.show(ui, |ui| {
// Prepare Story Bible reference ranges for highlighting
let bible_ranges: Vec<(usize, usize)> = self
.current_references
.iter()
.map(|r| (r.column, r.column + r.length))
.collect();
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
let job = build_editor_job(
text,
@@ -51,6 +58,7 @@ impl App {
&ranges,
&find_ranges,
find_active,
&bible_ranges,
wrap_width,
);
ui.fonts(|f| f.layout_job(job))
@@ -105,6 +113,8 @@ impl App {
self.find_needs_refresh = true;
self.spell_dirty = true;
self.spell_last_edit = Some(Instant::now());
// Update Story Bible references for the current file
self.update_current_references();
}
// Markdown formatting hotkeys, applied to the current selection
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
@@ -358,6 +368,7 @@ impl App {
/// get a red underline, grammar/style issues a blue one. `find` are
/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent
/// highlight; the one at index `find_active` (if any) is shaded more strongly.
/// `bible` are Story Bible reference ranges, highlighted with a distinctive color.
pub(super) fn build_editor_job(
text: &str,
size: f32,
@@ -365,6 +376,7 @@ pub(super) fn build_editor_job(
grammar: &[(usize, usize, bool)],
find: &[(usize, usize)],
find_active: Option<usize>,
bible: &[(usize, usize)],
wrap_width: f32,
) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat};
@@ -377,7 +389,7 @@ pub(super) fn build_editor_job(
return job;
}
// Split the text at every match boundary (grammar and find), then format
// Split the text at every match boundary (grammar, find, and bible), then format
// each run according to which ranges cover it.
let mut points: Vec<usize> = vec![0, text.len()];
for &(s, e, _) in grammar {
@@ -388,6 +400,10 @@ pub(super) fn build_editor_job(
points.push(s);
points.push(e);
}
for &(s, e) in bible {
points.push(s);
points.push(e);
}
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
points.sort_unstable();
points.dedup();
@@ -397,6 +413,8 @@ pub(super) fn build_editor_job(
// Translucent so the highlight reads in both light and dark themes.
let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70);
let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150);
// Story Bible reference highlight - purple/blue color
let bible_bg = egui::Color32::from_rgba_unmultiplied(138, 43, 226, 70);
let width = (size * 0.08).max(1.5);
for w in points.windows(2) {
@@ -421,6 +439,12 @@ pub(super) fn build_editor_job(
find_bg
};
}
// Check for Story Bible references and apply background highlight
// Only apply if not already highlighted by find
let is_find_highlight = find.iter().any(|&(s, e)| s < e && a >= s && b <= e);
if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) {
fmt.background = bible_bg;
}
job.append(&text[a..b], 0.0, fmt);
}
job
@@ -825,3 +849,19 @@ mod tests {
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
}
}
impl App {
/// Update the current_references list with Story Bible references found in the buffer.
pub(super) fn update_current_references(&mut self) {
if let Some(idx) = self.selected {
let file_path = self.files[idx].clone();
let mut refs = crate::story_bible::find_references(&self.buffer, &self.story_bible_index);
for r in refs.iter_mut() {
r.file_path = file_path.clone();
}
self.current_references = refs;
} else {
self.current_references.clear();
}
}
}
+17
View File
@@ -27,6 +27,7 @@ mod outline;
mod project;
mod grammar;
mod spelling;
mod story_bible_ui;
mod style;
mod ui;
mod util;
@@ -372,6 +373,14 @@ pub struct App {
watcher_rx: Option<mpsc::Receiver<WatcherMsg>>,
/// The file watcher thread handle, so it can be stopped when the workspace closes.
watcher_thread: Option<thread::JoinHandle<()>>,
/// Index of Story Bible items for cross-referencing with draft text.
story_bible_index: crate::story_bible::StoryBibleIndex,
/// Whether the Story Bible window is open.
show_story_bible: bool,
/// Currently selected Story Bible item path.
selected_bible_item: Option<String>,
/// References found in the current draft file.
current_references: Vec<crate::story_bible::StoryBibleReference>,
}
impl App {
@@ -472,6 +481,10 @@ impl App {
show_project_settings: false,
watcher_rx: None,
watcher_thread: None,
story_bible_index: crate::story_bible::StoryBibleIndex::new(),
show_story_bible: false,
selected_bible_item: None,
current_references: Vec::new(),
};
app.load_spell_dict();
app.open_workspace();
@@ -594,6 +607,10 @@ impl eframe::App for App {
self.characters_window(ctx);
}
if self.show_story_bible {
self.story_bible_window(ctx);
}
if self.show_manuscript_settings {
self.manuscript_settings_window(ctx);
}
+142
View File
@@ -0,0 +1,142 @@
//! Story Bible UI: window and reference navigation.
use super::*;
impl App {
/// Show the Story Bible window with indexed items and references.
pub(super) fn story_bible_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_story_bible;
egui::Window::new("Story Bible")
.open(&mut open)
.default_size(egui::vec2(400.0, 500.0))
.show(ctx, |ui| {
self.story_bible_window_contents(ui);
});
self.show_story_bible = open;
if !open {
self.selected_bible_item = None;
}
}
/// Contents of the Story Bible window.
fn story_bible_window_contents(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Search:");
let mut search_query = self.selected_bible_item.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut search_query).changed() {
self.selected_bible_item = Some(search_query);
}
});
ui.separator();
// Filter items based on search query
let all_items = self.story_bible_index.all_items();
let filtered_items: Vec<_> = if let Some(query) = &self.selected_bible_item {
if query.is_empty() {
all_items
} else {
self.story_bible_index.find_by_partial_name(query)
}
} else {
all_items
};
// Show item types as filter tabs
let mut all_types = Vec::new();
for item in &filtered_items {
if !all_types.contains(&item.item_type) {
all_types.push(item.item_type.clone());
}
}
// Sort and display type filters
let mut sorted_types = all_types.clone();
sorted_types.sort();
ui.horizontal_wrapped(|ui| {
if ui.button("All").clicked() {
self.selected_bible_item = None;
}
for t in &sorted_types {
if ui.button(t).clicked() {
// Filter by type - for now just show all and let search filter
}
}
});
ui.separator();
// Clone current_references to avoid borrow issues
let current_refs = self.current_references.clone();
let selected_item = self.selected_bible_item.clone();
// Use a RefCell to allow mutable access inside the closure
// Actually, simpler: use a local variable to track if we need to jump
let mut jump_target: Option<crate::story_bible::StoryBibleReference> = None;
// Display items
egui::ScrollArea::vertical().show(ui, |ui| {
for item in &filtered_items {
let selected = selected_item.as_ref() == Some(&item.path);
if ui
.selectable_label(selected, format!("{}: {}", item.item_type, item.name))
.clicked()
{
self.selected_bible_item = Some(item.path.clone());
}
// Show references to this item in draft
if selected {
ui.indent("bible_item_details", |ui| {
ui.label(format!("Type: {}", item.item_type));
ui.label(format!("Path: {}", item.path));
ui.separator();
// Find references to this item
let item_refs: Vec<_> = current_refs
.iter()
.filter(|r| {
r.identifier.eq_ignore_ascii_case(&item.id)
|| r.identifier.eq_ignore_ascii_case(&item.name)
})
.collect();
if !item_refs.is_empty() {
ui.label(egui::RichText::new(format!(
"{} reference(s) in current file:",
item_refs.len()
)).strong());
for ref_info in &item_refs {
if ui
.button(format!(
"Line {}: {}",
ref_info.line_number, ref_info.identifier
))
.clicked()
{
jump_target = Some((*ref_info).clone());
}
}
} else {
ui.label("No references in current file");
}
});
}
}
if filtered_items.is_empty() {
ui.label("No Story Bible items found");
}
});
// Perform the jump after the closure
if let Some(ref_info) = jump_target {
self.jump_to_reference(&ref_info);
}
}
/// Jump to a Story Bible reference in the editor.
pub(super) fn jump_to_reference(&mut self, ref_info: &crate::story_bible::StoryBibleReference) {
self.issue_jump = Some((ref_info.column, ref_info.column + ref_info.length));
}
}
+4
View File
@@ -331,6 +331,10 @@ impl App {
ui.close_menu();
self.show_characters = true;
}
if ui.button("📖 Story Bible…").clicked() {
ui.close_menu();
self.show_story_bible = true;
}
ui.separator();
let busy = self.beats_rx.is_some();
if ui
+48 -155
View File
@@ -93,6 +93,7 @@ impl App {
self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names();
self.load_characters();
self.build_story_bible_index();
self.autocomplete = None;
self.collapsed.clear();
// Ensure the manuscript folder (e.g., 06-First Draft) is always expanded
@@ -339,6 +340,42 @@ impl App {
.collect()
}
/// Build the Story Bible index from the Story Bible directory.
pub(super) fn build_story_bible_index(&mut self) {
use std::path::Path;
let ws = self.config.workspace.clone();
// Look for Story Bible directory - common locations
let story_bible_dirs = [
"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() {
self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path);
return;
}
}
// If no specific Story Bible directory found, try to find all character/location/lore dirs
let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"];
for dir_name in possible_dirs.iter() {
let dir_path = Path::new(&ws).join(dir_name);
if dir_path.exists() && dir_path.is_dir() {
let index = crate::story_bible::StoryBibleIndex::build(&dir_path);
// Merge with existing index
for (id, item) in index.items_by_id {
self.story_bible_index.items_by_id.insert(id, item.clone());
}
for (name, item) in index.items_by_name {
self.story_bible_index.items_by_name.insert(name, item);
}
}
}
}
/// Prose words across the whole manuscript, counting the open file from the
/// buffer so the total moves as you type.
pub(super) fn manuscript_words(&self) -> usize {
@@ -528,6 +565,8 @@ impl App {
self.selected = Some(idx);
self.dirty = false;
self.pending_delete = false;
// Update Story Bible references for the newly selected file
self.update_current_references();
// The rename box holds the whole workspace-relative path, so it doubles
// as the way to move a file between folders by typing.
self.rename_input = strip_md(&name).to_string();
@@ -679,7 +718,7 @@ impl App {
let draft_name = format!("{}/scene{:02}.md", draft_dir, scene_num);
// Planning file content
let planning_id = format!("scene_{:02d}_{:02d}_{:02d}", act, chapter, scene_num);
let planning_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
let planning_content = format!(r#"---
id: {}
title: "{}"
@@ -700,7 +739,6 @@ time: ""
conflict: ""
theme: ""
tone: ""
draft_file: "../../06-First Draft/Act {}/scene{:02}.md"
order: {}
created: "{}"
updated: "{}"
@@ -712,151 +750,39 @@ updated: "{}"
---
## Planning Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | {} |
| **Chapter** | {} |
| **Scene** | {} |
| **Type** | scene |
| **Order** | {} |
| **Draft File** | [Open Draft](../../06-First Draft/Act {}/scene{:02}.md) |
---
## Creative Elements
### POV
>
### Characters
### Setting
>
### Date & Time
>
---
## Story Structure
### Conflict
>
### Theme
>
### Tone
>
---
## Word Count Goals
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
---
## Scene Planning
### The Setup
>
### The Reveal
>
### Things to Note
-
-
---
## Status Tracking
- [ ] Scene outline complete
- [ ] Character arcs aligned
- [ ] Location details verified
- [ ] Conflict established
- [ ] Word count target set
- [ ] Ready for drafting
---
**Draft File:** [Open Draft](../../06-First Draft/Act {}/scene{:02}.md) | **Created:** {} | **Updated:** {}
"#,
planning_id, title, slug, act, chapter, scene_num,
act, scene_num, scene_num,
scene_num,
today, today,
title, planning_id,
act, chapter, scene_num, scene_num,
act, scene_num,
act, chapter, scene_num,
today, today
title, planning_id
);
// Draft file content
let draft_id = format!("draft_scene_{:02d}_{:02d}_{:02d}", act, chapter, scene_num);
let draft_id = format!("draft_scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
let draft_content = format!(r#"---
id: {}
title: "{}"
slug: {}
planning_template: "../../05-Plot/act_{:02}/scene_{:02}.md"
type: draft
status: not_started
act: {}
chapter: {}
scene: {}
type: draft
status: not_started
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
pov: ""
version: 1
created: "{}"
updated: "{}"
---
# {}
**Draft File** | **ID:** {} | **Status:** Not Started
---
## Planning Reference
> **Linked to:** [Planning Template](../../05-Plot/act_{:02}/scene_{:02}.md)
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | {} |
| **Chapter** | {} |
| **Scene** | {} |
| **Type** | draft |
| **POV** | Not set |
| **Version** | 1 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 (from planning) |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
**Draft File** | **ID:** {}
---
@@ -866,49 +792,16 @@ updated: "{}"
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Planning Template:** [Open Planning](../../05-Plot/act_{:02}/scene_{:02}.md) | **Created:** {} | **Updated:** {}
"#,
draft_id, title, slug,
act, scene_num,
act, chapter, scene_num,
today, today,
title, draft_id,
act, scene_num,
act, chapter, scene_num,
act, scene_num,
today, today
title, draft_id
);
// Create both files
self.insert_new_file(planning_name, planning_content);
self.insert_new_file(draft_name, draft_content);
self.insert_new_file(draft_name.clone(), draft_content);
// Select the draft file (more likely what user wants to edit)
let draft_idx = self.files.iter().position(|f| *f == draft_name).unwrap_or(0);