//! 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 auto_snapshot; 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 synopsis; mod ui; mod util; mod workspace; mod workspace_search; // 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, 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); /// 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, /// The `POV:` line, if any. pov: Option, /// The `Word Count Target:` goal, if any. goal: Option, /// 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, } 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 { 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, /// 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, } 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, /// 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, /// Folders the user has collapsed in the file panel, as workspace-relative /// paths. Purely a view concern, so it is not persisted. collapsed: HashSet, /// Per-file chapter title overrides (file name -> title). Missing/empty means /// the title is derived automatically at export time. titles: HashMap, /// Per-day manuscript word-count log, for the writing streak in the /// status bar. Lives in the workspace (`word_log.json`) and syncs with /// git the same way `titles`/`order.json` do. word_log: crate::word_log::WordLog, /// Editing buffer for the selected file's chapter title override. title_input: String, selected: Option, /// 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, dirty: bool, /// Byte offset of the primary cursor in `buffer`, as of the last frame. /// Used by typewriter mode to notice the cursor moved (and re-center it) /// versus the writer scrolling the view manually, and to find the current /// paragraph to keep in focus. cursor_pos: usize, /// 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>>, /// 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, /// 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, /// 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, /// 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, /// Grammar/spelling issues from the last LanguageTool check. lt_matches: Vec, /// 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>, /// 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>>, /// 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, /// Request the editor to scroll the active match into view next frame. find_scroll: bool, /// Whether the workspace-wide search window is visible. show_workspace_search: bool, /// The workspace search text. ws_query: String, /// The workspace replacement text. ws_replace: String, /// Whether the workspace search is case-sensitive. ws_case_sensitive: bool, /// The last workspace search's results: one entry per file with at least /// one match — its name, total match count, and a few line snippets. ws_results: Vec<(String, usize, Vec)>, /// One-line status for the workspace search window. ws_status: String, /// Active header-field autocomplete popup, if any. autocomplete: Option, /// 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, /// Known header field names (built-ins + those learned from the workspace). field_names: Vec, /// The loaded offline spell-check dictionary (shared with the worker thread). spell_dict: Option>, /// Dictionaries available to choose from (bundled + discovered on disk). spell_dicts: Vec, /// Live spelling matches and the buffer text they were computed against. spell_matches: Vec, 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, /// In-flight background spell check, if any. spell_rx: Option>, /// 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, /// The project's cast, read from its character sheets. characters: Vec, /// 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 the Synopsis window is open. show_synopsis: 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, /// Whether the diff window is open, and what it is showing. show_diff: bool, diff_text: String, diff_title: String, /// Commit the diff window is comparing against. `None` means the last /// commit (`HEAD`); `Some(hash)` means an older Auto-Snapshot picked from /// the Snapshots window. diff_base: Option, /// Human-readable label for `diff_base`, shown in the diff window's header. diff_base_label: 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, /// 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>>, /// 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, /// 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, /// File stem of the proposal the beats came from, for the default save name. beats_source_stem: Option, /// 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>, /// Whether the new-project settings window is open. show_project_settings: bool, /// Receiver for file change notifications from the workspace watcher. watcher_rx: Option>, /// The file watcher thread handle, so it can be stopped when the workspace closes. watcher_thread: Option>, /// 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, /// References found in the current draft file. current_references: Vec, /// 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, /// 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, /// 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, /// 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, /// Whether the caret was in the Story Bible editor's text area last frame, /// which is what decides where `Ctrl+S` sends its save. story_bible_editor_focused: bool, /// List of auto-snapshots for rollback. snapshots: Vec, /// Word count at the last auto-snapshot. last_snapshot_word_count: usize, /// Whether the snapshots window is open. show_snapshots: bool, } 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(), word_log: crate::word_log::WordLog::default(), title_input: String::new(), selected: None, buffer: String::new(), header_stash: None, dirty: false, cursor_pos: 0, 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, show_workspace_search: false, ws_query: String::new(), ws_replace: String::new(), ws_case_sensitive: false, ws_results: Vec::new(), ws_status: String::new(), 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, show_synopsis: false, open_outline_file: None, show_diff: false, diff_text: String::new(), diff_title: String::new(), diff_base: None, diff_base_label: "the last commit".to_string(), 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(), 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, story_bible_editor_focused: false, snapshots: Vec::new(), last_snapshot_word_count: 0, show_snapshots: false, }; 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 whatever is being typed in. Usually that is the open // manuscript file, but the Story Bible editor is a second text area with // its own file, and sending its keystroke to the manuscript both loses // the edit and writes a file the user was not looking at. if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) { match self.story_bible_editor_path.clone() { Some(path) if self.show_story_bible_editor && self.story_bible_editor_focused => { self.save_story_bible_item(path); } _ => { 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+Shift+F searches the whole // workspace instead; Ctrl+H opens find focused on replace. if ctx.input(|i| i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::F)) { self.open_find(true); } if ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::F)) { self.open_workspace_search(); } 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_snapshots { self.snapshots_window(ctx); } if self.show_manuscript_settings { self.manuscript_settings_window(ctx); } if self.show_outline { self.outline_window(ctx); } if self.show_synopsis { self.synopsis_window(ctx); } if self.show_workspace_search { self.workspace_search_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(); } }