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:
+106
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user