6ca8681d08
Five productivity features: search/replace across the whole workspace (with a safety commit before Replace all), a Synopsis window compiling every chapter's slug, a Diff button per Auto-Snapshot to compare against an older snapshot instead of only HEAD, typewriter-style cursor centering and paragraph dimming gated on Distraction-free mode, and a per-day word-count streak persisted to word_log.json alongside order.json/titles.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
761 lines
29 KiB
Rust
761 lines
29 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 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<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>,
|
|
/// 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<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,
|
|
/// 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<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,
|
|
/// 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<String>)>,
|
|
/// One-line status for the workspace search window.
|
|
ws_status: String,
|
|
/// 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 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<String>,
|
|
/// 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<String>,
|
|
/// 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<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>,
|
|
/// 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.
|
|
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>,
|
|
/// 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<crate::app::auto_snapshot::SnapshotInfo>,
|
|
/// 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();
|
|
}
|
|
}
|