Auto-link Story Bible mentions in the draft, with a hover card to the entry

Bare proper nouns in prose ("Bixby walked in") now resolve against the
Story Bible automatically, the same as the existing [[wiki-links]] and
@mentions markup — no manual linking required. Matching is case-sensitive
and requires a capitalized, 3+ character match to a sheet's name or alias,
which is what keeps a character named "Hope" or "Will" from lighting up
on every unrelated use of the word; the longest phrase wins an overlap
("Bixby Reynolds" over "Bixby"), and a bare match never overrides the
author's own explicit markup.

Hovering a highlighted mention shows a card with the item's name, type
(Person/Place/Thing) and a summary (from a new summary:/description:
frontmatter field), with a link that opens the entry in the Story Bible
editor. The card appears after a short delay and stays open while the
pointer crosses onto it to reach the link.

Along the way:
- StoryBibleReference now carries absolute byte offsets (start/end) and
  the resolved item_id, replacing column/length, which were actually
  per-line and were being used as whole-buffer offsets — every reference
  past a file's first line highlighted and jumped-to in the wrong place.
  find_references() also now walks the buffer one char at a time rather
  than one byte, which could previously land mid-character and panic on
  any manuscript with accented letters or an em dash.
- build_story_bible_index() had its own, separately hardcoded list of
  Story Bible directory names that had drifted from
  find_story_bible_directory()'s list and no longer included
  03-Story-Bible, the bundled template's actual folder — so the index,
  and everything built on it, was silently empty for every project made
  from that template. It now reuses the one list.
- Story Bible Editor window: its tree and content ScrollAreas (and the
  content TextEdit) had no id_salt, so egui treated them as the same
  widget — fixed with explicit salts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 11:01:54 -05:00
parent ef8032b300
commit ab9aa2afce
6 changed files with 436 additions and 84 deletions
+106 -1
View File
@@ -47,7 +47,7 @@ impl App {
let bible_ranges: Vec<(usize, usize)> = self
.current_references
.iter()
.map(|r| (r.column, r.column + r.length))
.map(|r| (r.start, r.end))
.collect();
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
@@ -335,6 +335,48 @@ impl App {
} else {
self.autocomplete = None;
}
// Which Story Bible mention (if any) is the pointer over right
// now, and where would its hover card anchor?
let hovered_ref = output.response.hover_pos().and_then(|pos| {
let local = pos - output.galley_pos;
let cursor = output.galley.cursor_from_pos(local);
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
self.current_references
.iter()
.find(|r| byte >= r.start && byte < r.end)
});
match hovered_ref {
Some(r) if self.bible_hover_item.as_deref() != Some(r.item_id.as_str()) => {
let (cs, ce) = (
byte_to_char(&self.buffer, r.start),
byte_to_char(&self.buffer, r.end),
);
let rect = output
.galley
.pos_from_ccursor(egui::text::CCursor::new(cs))
.union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce)))
.translate(output.galley_pos.to_vec2());
self.bible_hover_item = Some(r.item_id.clone());
self.bible_hover_since = Some(Instant::now());
self.bible_hover_anchor = rect;
}
Some(_) => {} // the same word is still hovered — keep its timer/anchor
None => {
// Not over a mention — only drop the card if the
// pointer isn't over the card itself either (the user
// moving toward its "Open" link).
let over_card = self
.bible_hover_card_rect
.zip(ui.ctx().pointer_hover_pos())
.is_some_and(|(rect, pos)| rect.contains(pos));
if !over_card {
self.bible_hover_item = None;
self.bible_hover_since = None;
self.bible_hover_card_rect = None;
}
}
}
});
// Header-field autocomplete popup, floating just below the caret.
@@ -358,6 +400,69 @@ impl App {
if let Some(field) = clicked_field {
self.apply_field_completion(ui.ctx(), &field);
}
// Story Bible hover card: name, type and summary for the mentioned
// item, with a link to open its full entry. Shown after a short
// delay so it doesn't flash across every name the pointer passes
// over on its way somewhere else, and it stays open while the
// pointer crosses onto the card to reach that link (tracked via
// `bible_hover_card_rect`, set below and checked above).
const HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(350);
let mut open_bible_item: Option<String> = None;
let hover = self.bible_hover_item.clone().zip(self.bible_hover_since);
self.bible_hover_card_rect = None;
if let Some((item_id, since)) = hover {
let elapsed = since.elapsed();
if elapsed < HOVER_DELAY {
ui.ctx().request_repaint_after(HOVER_DELAY - elapsed);
} else if let Some(item) = self.story_bible_index.find_by_id(&item_id).cloned() {
let resp = egui::Area::new(egui::Id::new("bible_hover_card"))
.order(egui::Order::Foreground)
.fixed_pos(self.bible_hover_anchor.left_bottom() + egui::vec2(0.0, 4.0))
.constrain(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_max_width(280.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&item.name).strong());
ui.label(
egui::RichText::new(crate::story_bible::type_label(
&item.item_type,
))
.weak()
.small(),
);
});
match &item.summary {
Some(s) => {
ui.label(s);
}
None => {
ui.label(
egui::RichText::new("No summary yet.").weak().italics(),
);
}
}
ui.separator();
if ui.link("Open in Story Bible \u{2192}").clicked() {
open_bible_item = Some(item.path.clone());
}
});
});
self.bible_hover_card_rect = Some(resp.response.rect);
} else {
// The item vanished (sheet deleted, index gone stale) — stop trying.
self.bible_hover_item = None;
self.bible_hover_since = None;
}
}
if let Some(path) = open_bible_item {
self.show_story_bible_editor = true;
self.load_story_bible_item(&path);
self.bible_hover_item = None;
self.bible_hover_since = None;
self.bible_hover_card_rect = None;
}
}
}
+16
View File
@@ -383,6 +383,18 @@ pub struct App {
selected_bible_item: Option<String>,
/// References found in the current draft file.
current_references: Vec<crate::story_bible::StoryBibleReference>,
/// The Story Bible item whose hover card is (or was just) showing over the
/// editor, so the card can stay open while the pointer crosses from the
/// mentioned word onto the card itself to click its link.
bible_hover_item: Option<String>,
/// When the current hover began, so the card only appears after a short
/// delay rather than flickering across every name the pointer passes over.
bible_hover_since: Option<Instant>,
/// On-screen rect of the mentioned word, to anchor the hover card under.
bible_hover_anchor: egui::Rect,
/// On-screen rect the hover card itself occupied last frame, so hovering
/// the card (to reach its link) counts as still hovering the reference.
bible_hover_card_rect: Option<egui::Rect>,
/// Whether the Story Bible breakout editor window is open.
show_story_bible_editor: bool,
/// The content being edited in the Story Bible editor.
@@ -502,6 +514,10 @@ impl App {
show_story_bible: false,
selected_bible_item: None,
current_references: Vec::new(),
bible_hover_item: None,
bible_hover_since: None,
bible_hover_anchor: egui::Rect::NOTHING,
bible_hover_card_rect: None,
show_story_bible_editor: false,
story_bible_editor_content: String::new(),
story_bible_editor_path: None,
+3
View File
@@ -241,6 +241,7 @@ impl App {
// Scroll area for tree
egui::ScrollArea::vertical()
.id_salt("story_bible_editor_tree")
.auto_shrink(false)
.show(ui, |ui| {
if tree.is_empty() {
@@ -271,9 +272,11 @@ impl App {
// 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);
+2 -5
View File
@@ -95,10 +95,7 @@ impl App {
// 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)
})
.filter(|r| r.item_id == item.id)
.collect();
if !item_refs.is_empty() {
@@ -137,6 +134,6 @@ impl App {
/// 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));
self.issue_jump = Some((ref_info.start, ref_info.end));
}
}
+18 -17
View File
@@ -341,37 +341,38 @@ impl App {
}
/// Build the Story Bible index from the Story Bible directory.
///
/// Uses [`Self::find_story_bible_directory`] for the primary lookup —
/// this used to keep its own, separately-hardcoded list of directory
/// names, which had drifted out of sync and no longer included
/// `03-Story-Bible`, the bundled template's actual folder. That silently
/// left the index empty (no highlighting, no hover cards, no hits in the
/// Story Bible window) for every project made from the snowflake
/// template, so there is now exactly one place that name is spelled.
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 let Some(dir_path) = self.find_story_bible_directory() {
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
self.story_bible_index = crate::story_bible::StoryBibleIndex::default();
// No single Story Bible directory found — fall back to merging
// whatever scattered character/location/lore folders exist.
let ws = self.config.workspace.clone();
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);
}
for (alias, item) in index.items_by_alias {
self.story_bible_index.items_by_alias.insert(alias, item);
}
}
}
}