Files
md-manuscript/src/app/mod.rs
T
landon dcb97da518 feat: Add Story Bible breakout editor window
- Add new module src/app/story_bible_editor.rs for dedicated Story Bible editing
- Add 'Edit -> Story Bible' menu option to open the editor
- Tree view (left 1/3) shows Story Bible directory structure with collapsible folders
- Editor (right 2/3) allows editing selected Story Bible items
- Auto-load first file on open, support Ctrl+S to save
- Rebuild Story Bible index after saving to pick up changes
- Update README.md with Story Bible Editor documentation

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-16 11:21:15 -05:00

661 lines
25 KiB
Rust

//! The application state and its egui front end.
//!
//! [`App`] holds every piece of UI state; the behaviour hanging off it is
//! split across the sibling modules below, which are children of this one so
//! they can reach `App`'s private fields without widening its interface.
use crate::config::Config;
use crate::gitsync;
use crate::odt::{self, Chapter};
use crate::order;
use eframe::egui;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
mod autocomplete;
mod beats;
mod characters;
mod diff;
mod editor;
mod file_list;
mod find;
mod grammar;
mod outline;
mod project;
mod spelling;
mod story_bible_editor;
mod story_bible_ui;
mod style;
mod ui;
mod util;
mod workspace;
// Pulled up so the sibling modules reach these free helpers through their own
// `use super::*;`. Modules whose helpers are only used locally aren't listed.
use self::autocomplete::*;
use self::file_list::*;
use self::grammar::*;
use self::project::*;
use self::spelling::*;
use self::style::*;
use self::util::*;
/// Result of a background grammar check: the matches, or an error message.
type LtCheckResult = Result<Vec<crate::langtool::Match>, String>;
/// Channel payload from a background check: the text that was checked, paired
/// with its result (so the app can confirm the buffer hasn't changed since).
type LtCheckMsg = (String, LtCheckResult);
/// Channel payload from a background spell check: the checked text and the
/// spelling matches found in it.
type SpellCheckMsg = (String, Vec<crate::langtool::Match>);
/// Message from the file watcher thread indicating the workspace needs a refresh.
#[derive(Debug, Clone)]
struct WatcherMsg;
/// Which check currently owns the editor's underlines and the issues panel.
#[derive(Clone, Copy, PartialEq, Eq)]
enum IssueSource {
/// Fresh LanguageTool results for the current buffer (spelling + grammar).
LanguageTool,
/// Live offline spell-check results (spelling only).
Spell,
/// Nothing current to show.
None,
}
/// Cached per-file header info for the file list: the hover tooltip fields plus
/// the word-count target and current prose length (for the per-row progress bar).
#[derive(Default, Clone)]
struct FileMeta {
/// The `# Slug:` synopsis line, if any.
slug: Option<String>,
/// The `POV:` line, if any.
pov: Option<String>,
/// The `Word Count Target:` goal, if any.
goal: Option<crate::preprocess::WordGoal>,
/// Prose (body) word count captured with the rest of this metadata.
prose_words: usize,
/// The `Status:` line, if any — the revision stage this file has reached.
status: Option<String>,
}
impl FileMeta {
/// Extract the cached fields from a document.
fn from_markdown(text: &str, marker: &str) -> Self {
let h = crate::preprocess::parse(text, marker);
FileMeta {
slug: h.slug,
pov: h.pov,
goal: h.goal,
prose_words: count_words(&h.body),
status: crate::preprocess::field(text, marker, "Status"),
}
}
/// The tooltip text — a `POV:` line then the slug synopsis — or `None` when
/// the file carries neither field.
fn tooltip(&self) -> Option<String> {
let mut lines = Vec::new();
if let Some(pov) = &self.pov {
lines.push(format!("POV: {pov}"));
}
if let Some(slug) = &self.slug {
lines.push(slug.clone());
}
(!lines.is_empty()).then(|| lines.join("\n"))
}
}
/// Stable widget id for the manuscript editor's text field, so its cursor and
/// focus can be driven when inserting a header-field autocompletion.
const EDITOR_ID: &str = "manuscript_editor";
/// Header field names offered by autocomplete out of the box; merged with any
/// field names learned from the workspace's own files.
const DEFAULT_FIELD_NAMES: &[&str] = &[
"Title",
"Slug",
"POV",
"Characters",
"Setting",
"Conflict",
"Feeling to convey",
"Word Count Target",
"The Setup",
"The Reveal",
"Things to Note",
"Summary",
"Notes",
"Status",
"Location",
"Time",
"Goal",
"Motivation",
"Theme",
"Mood",
];
/// An active header-field autocomplete popup.
struct Autocomplete {
/// Matching field names (display form), best first.
matches: Vec<String>,
/// Highlighted match index.
selected: usize,
/// The lowercased partial that produced `matches`, so the selection stays
/// put while the user keeps typing the same prefix.
partial: String,
/// Byte offset on the line where the field-name text begins (replaced on
/// accept), and the caret byte offset (end of the replaced range).
field_start: usize,
cursor: usize,
/// Screen position just below the caret, where the popup is anchored.
anchor: egui::Pos2,
}
/// An owned snapshot of one issue for the results panel, decoupled from `self`
/// so the panel can render without holding a borrow across its click handling.
struct IssueItem {
/// Index of this item in the source match list (for applying a fix).
idx: usize,
/// The offending text.
snippet: String,
message: String,
spelling: bool,
replacements: Vec<String>,
}
pub struct App {
config: Config,
/// The manuscript folder within the workspace when the workspace is a
/// project root — files under it are the book, everything else is
/// reference. `None` when the workspace is itself the manuscript.
manuscript_dir: Option<String>,
/// Ordered markdown files, as workspace-relative paths with `/` separators
/// (`part-1/ch-03.md`). Always held in folder-tree order — see
/// [`crate::order::tree_order`] — so this list reads exactly as the file
/// panel draws it and as the export concatenates it.
files: Vec<String>,
/// Folders the user has collapsed in the file panel, as workspace-relative
/// paths. Purely a view concern, so it is not persisted.
collapsed: HashSet<String>,
/// Per-file chapter title overrides (file name -> title). Missing/empty means
/// the title is derived automatically at export time.
titles: HashMap<String, String>,
/// Editing buffer for the selected file's chapter title override.
title_input: String,
selected: Option<usize>,
/// Editor contents for the selected file. While the header is collapsed
/// this is the prose only; [`App::document`] puts the file back together.
buffer: String,
/// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>,
dirty: bool,
/// Editable copy of the workspace path shown in the top bar.
workspace_input: String,
/// Editable copy of the export path.
export_input: String,
new_name: String,
rename_input: String,
pending_delete: bool,
/// Whether the rename dialog opened from a file row's context menu is up.
show_rename: bool,
/// Set alongside `show_rename` so the dialog's box takes focus on the frame
/// it appears, rather than needing a click before you can type.
rename_focus: bool,
/// Whether the delete confirmation opened from that menu is up.
confirm_delete: bool,
/// Editable copy of the workspace repository's `origin` URL. Read back from
/// git whenever the repository changes, never persisted to the config —
/// the remote belongs to the checkout, not to the app.
remote_input: String,
/// Whether the git-remote dialog is up.
show_git_remote: bool,
/// Name to give the repository the Gitea section would create.
gitea_repo_name: String,
/// Result channel for the in-flight Gitea call, if there is one. Its
/// presence is also what greys the button out, so only one can run.
gitea_rx: Option<std::sync::mpsc::Receiver<Result<crate::gitea::Repo, String>>>,
/// What the Gitea section is saying about the last (or running) attempt.
gitea_status: String,
status: String,
show_log: bool,
git_log: String,
is_repo: bool,
/// The git work tree currently backing the workspace, if any. Equals the
/// workspace when the folder is itself a repo root; a parent directory when
/// the user has adopted an enclosing repository.
repo_root: Option<PathBuf>,
/// A repository found in a parent directory that is awaiting the user's
/// confirmation before it is adopted for the current workspace.
pending_repo: Option<PathBuf>,
/// Each file's word count captured when the workspace was opened, used as the
/// per-file baseline for the "this session" delta.
session_start_counts: HashMap<String, usize>,
/// Cached header info per file (name -> slug/POV/goal/prose) for the file
/// list's hover tooltip and per-row progress bar. Filled when the workspace
/// opens and refreshed on save; the selected file is read live from the buffer.
file_meta: HashMap<String, FileMeta>,
/// Grammar/spelling issues from the last LanguageTool check.
lt_matches: Vec<crate::langtool::Match>,
/// The exact buffer text the current `lt_matches` were computed against;
/// underlines and replacements only apply while the buffer still equals it.
lt_checked_text: String,
/// One-line status for the grammar checker (issue count, error, or progress).
lt_status: String,
/// Receiver for an in-flight background check, if any.
lt_rx: Option<std::sync::mpsc::Receiver<LtCheckMsg>>,
/// Whether the grammar results panel is visible.
show_lt_panel: bool,
/// Whether the LanguageTool settings window is open.
show_settings: bool,
/// Receiver for an in-flight "Test connection" from the settings window.
settings_test_rx: Option<std::sync::mpsc::Receiver<Result<usize, String>>>,
/// Result line for the settings window's "Test connection" button.
settings_test_status: String,
/// Whether the markdown cheatsheet window is open.
show_cheatsheet: bool,
/// Whether the find/replace bar above the editor is visible.
show_find: bool,
/// The search text.
find_query: String,
/// The replacement text.
replace_query: String,
/// Whether the search is case-sensitive.
find_case_sensitive: bool,
/// Byte ranges of the current search matches within `buffer` (in order).
find_matches: Vec<(usize, usize)>,
/// Index into `find_matches` of the "current" match (for Next/Prev/Replace).
find_active: usize,
/// One-line status for the find bar (e.g. "3 of 12" or "No matches").
find_status: String,
/// Set when the matches need recomputing (query/case/buffer changed).
find_needs_refresh: bool,
/// Request keyboard focus for a find-bar field next frame: `Some(true)` for
/// the search field, `Some(false)` for the replace field.
find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame.
find_scroll: bool,
/// Active header-field autocomplete popup, if any.
autocomplete: Option<Autocomplete>,
/// A partial the user dismissed with Esc; the popup stays closed until the
/// partial changes, so Esc isn't undone by the next-frame recompute.
ac_dismissed: Option<String>,
/// Known header field names (built-ins + those learned from the workspace).
field_names: Vec<String>,
/// The loaded offline spell-check dictionary (shared with the worker thread).
spell_dict: Option<Arc<spellbook::Dictionary>>,
/// Dictionaries available to choose from (bundled + discovered on disk).
spell_dicts: Vec<crate::spell::DictEntry>,
/// Live spelling matches and the buffer text they were computed against.
spell_matches: Vec<crate::langtool::Match>,
spell_checked_text: String,
/// Set when the buffer changed and a re-check is owed.
spell_dirty: bool,
/// When the buffer was last edited, for debouncing the live check. `None`
/// means "check as soon as possible" (e.g. right after opening a file).
spell_last_edit: Option<Instant>,
/// In-flight background spell check, if any.
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
/// One-line status for the spell checker (dictionary name, count, or error).
spell_status: String,
/// Names and invented terms the checker accepts on top of the dictionary,
/// loaded from the workspace's `wordlist.json`.
wordlist: crate::spell::WordList,
/// Whether the word-list window is open.
show_wordlist: bool,
/// When set, the file panel shows only files whose `Status:` matches.
status_filter: Option<String>,
/// The project's cast, read from its character sheets.
characters: Vec<crate::characters::Character>,
/// Whether the Characters window is open.
show_characters: bool,
/// Whether the manuscript-details window is open.
show_manuscript_settings: bool,
/// Whether the Outline window is open.
show_outline: bool,
/// Whether to open an outline file from a folder context menu.
/// Contains the workspace-relative path of the outline file to open.
open_outline_file: Option<String>,
/// Whether the diff window is open, and what it is showing.
show_diff: bool,
diff_text: String,
diff_title: String,
/// Text field for adding a word by hand.
wordlist_input: String,
/// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any.
spell_menu: Option<usize>,
/// Issues the user has waved away, as (offending text, message) pairs. Both
/// checkers drop matches keyed here, so a dismissed complaint stays hidden
/// when the file is checked again. Reset when a different file is opened.
dismissed: HashSet<(String, String)>,
/// Byte range in `buffer` the editor should select and scroll into view next
/// frame, set when an issue is clicked in the results panel.
issue_jump: Option<(usize, usize)>,
/// Whether the Mistral settings window is open.
show_mistral_settings: bool,
/// Whether the new-file template settings window is open.
show_template_settings: bool,
/// In-flight background plot-beat generation, if any.
beats_rx: Option<std::sync::mpsc::Receiver<Result<crate::mistral::Beats, String>>>,
/// Acts the last generation was asked to fill. Empty means it wrote a whole
/// three-act sheet; non-empty means the proposal was already partly beaten
/// out and only these acts came back.
beats_filled: Vec<crate::mistral::Act>,
/// One-line status for the plot-beat generator.
beats_status: String,
/// The generated (and user-editable) plot beats; `Some` opens the results
/// window. Holds an empty string while generating or on error.
beats_output: Option<String>,
/// File stem of the proposal the beats came from, for the default save name.
beats_source_stem: Option<String>,
/// Whether the new-project dialog is open.
show_new_project: bool,
/// New-project dialog fields.
np_name: String,
np_author: String,
np_description: String,
/// Directory the project will be created in.
np_parent: String,
/// One-line status for the new-project dialog (validation or failure).
np_status: String,
/// In-flight background project generation, if any.
np_rx: Option<std::sync::mpsc::Receiver<ProjectMsg>>,
/// Whether the new-project settings window is open.
show_project_settings: bool,
/// Receiver for file change notifications from the workspace watcher.
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>,
/// Whether the Story Bible breakout editor window is open.
show_story_bible_editor: bool,
/// The content being edited in the Story Bible editor.
story_bible_editor_content: String,
/// The workspace-relative path of the file being edited in the Story Bible editor.
story_bible_editor_path: Option<String>,
}
impl App {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
apply_global_style(&cc.egui_ctx);
let config = Config::load();
let mut app = App {
workspace_input: config.workspace.display().to_string(),
export_input: config.export_path.display().to_string(),
config,
manuscript_dir: None,
files: Vec::new(),
collapsed: HashSet::new(),
titles: HashMap::new(),
title_input: String::new(),
selected: None,
buffer: String::new(),
header_stash: None,
dirty: false,
new_name: String::new(),
rename_input: String::new(),
pending_delete: false,
show_rename: false,
rename_focus: false,
confirm_delete: false,
remote_input: String::new(),
show_git_remote: false,
gitea_repo_name: String::new(),
gitea_rx: None,
gitea_status: String::new(),
status: String::new(),
show_log: false,
git_log: String::new(),
is_repo: false,
repo_root: None,
pending_repo: None,
session_start_counts: HashMap::new(),
file_meta: HashMap::new(),
lt_matches: Vec::new(),
lt_checked_text: String::new(),
lt_status: String::new(),
lt_rx: None,
show_lt_panel: false,
show_settings: false,
settings_test_rx: None,
settings_test_status: String::new(),
show_cheatsheet: false,
show_find: false,
find_query: String::new(),
replace_query: String::new(),
find_case_sensitive: false,
find_matches: Vec::new(),
find_active: 0,
find_status: String::new(),
find_needs_refresh: false,
find_focus: None,
find_scroll: false,
autocomplete: None,
ac_dismissed: None,
field_names: Vec::new(),
spell_dict: None,
spell_dicts: crate::spell::available(),
spell_matches: Vec::new(),
spell_checked_text: String::new(),
spell_dirty: true,
spell_last_edit: None,
spell_rx: None,
spell_status: String::new(),
wordlist: crate::spell::WordList::default(),
show_wordlist: false,
status_filter: None,
characters: Vec::new(),
show_characters: false,
show_manuscript_settings: false,
show_outline: false,
open_outline_file: None,
show_diff: false,
diff_text: String::new(),
diff_title: String::new(),
wordlist_input: String::new(),
spell_menu: None,
dismissed: HashSet::new(),
issue_jump: None,
show_mistral_settings: false,
show_template_settings: false,
beats_rx: None,
beats_status: String::new(),
beats_filled: Vec::new(),
beats_output: None,
beats_source_stem: None,
show_new_project: false,
np_name: String::new(),
np_author: String::new(),
np_description: String::new(),
np_parent: String::new(),
np_status: String::new(),
np_rx: None,
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(),
show_story_bible_editor: false,
story_bible_editor_content: String::new(),
story_bible_editor_path: None,
};
app.load_spell_dict();
app.open_workspace();
app
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Ctrl+S saves.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) {
self.dirty = true;
self.save_current();
}
// Ctrl+D folds the panels away for distraction-free writing, and back.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D)) {
self.toggle_distraction_free();
}
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
self.open_find(true);
}
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) {
self.open_find(false);
}
// Esc closes the find bar.
if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.show_find = false;
}
// Recompute search matches if the query, case option, or buffer changed.
if self.show_find && self.find_needs_refresh {
self.refresh_find();
}
self.poll_watcher();
self.poll_lt();
self.poll_settings_test();
self.poll_spell();
self.poll_beats();
self.poll_new_project();
self.maybe_start_spell_check(ctx);
self.menu_bar(ctx);
self.top_bar(ctx);
self.left_pane(ctx);
if self.show_lt_panel {
self.lt_panel(ctx);
}
if self.show_log {
egui::TopBottomPanel::bottom("gitlog")
.resizable(true)
.default_height(160.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("git output").strong());
if ui.button("Hide").clicked() {
self.show_log = false;
}
});
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.git_log.as_str())
.code_editor()
.desired_width(f32::INFINITY),
);
});
});
}
self.central(ctx);
if self.show_settings {
self.settings_window(ctx);
}
if self.show_mistral_settings {
self.mistral_settings_window(ctx);
}
if self.show_template_settings {
self.template_settings_window(ctx);
}
if self.show_rename {
self.rename_window(ctx);
}
if self.confirm_delete {
self.confirm_delete_window(ctx);
}
if self.show_git_remote {
self.git_remote_window(ctx);
}
self.poll_gitea_create();
if self.show_new_project {
self.new_project_window(ctx);
}
if self.show_project_settings {
self.project_settings_window(ctx);
}
if self.beats_output.is_some() {
self.beats_window(ctx);
}
if self.show_wordlist {
self.wordlist_window(ctx);
}
if self.show_characters {
self.characters_window(ctx);
}
if self.show_story_bible {
self.story_bible_window(ctx);
}
if self.show_story_bible_editor {
self.story_bible_editor_window(ctx);
}
if self.show_manuscript_settings {
self.manuscript_settings_window(ctx);
}
if self.show_outline {
self.outline_window(ctx);
}
if self.show_diff {
self.diff_window(ctx);
}
if self.open_outline_file.is_some() {
self.outline_popup_window(ctx);
}
if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
}
if self.pending_repo.is_some() {
self.repo_prompt(ctx);
}
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.save_current();
self.persist_order();
self.persist_titles();
self.stop_watcher();
self.config.save();
}
}