Split app.rs into an app/ module tree
src/app.rs had grown to 3,700 lines, ~2,400 of them a single impl App block. Move it to src/app/mod.rs and spread the behaviour across eleven child modules grouped by feature: workspace, grammar, spelling, beats, find, editor, autocomplete, file_list, ui, style and util. The new modules are children of app rather than siblings, so they still reach App's private fields without widening its interface; methods and free helpers that are now used across module boundaries are marked pub(super). mod.rs keeps the state types, App::new and the eframe::App update loop. This is pure code motion - every non-blank line of the original file reappears exactly once, and the only edits are the pub(super) markers, the module scaffolding, and rewrapping five signatures that the added prefix pushed past 100 columns. Largest file is now editor.rs at 520 lines. Tests still 56/56, and cargo clippy --release reports the same five warnings as before the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
+437
@@ -0,0 +1,437 @@
|
||||
//! 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;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod autocomplete;
|
||||
mod beats;
|
||||
mod editor;
|
||||
mod file_list;
|
||||
mod find;
|
||||
mod grammar;
|
||||
mod spelling;
|
||||
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::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>);
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this entry carries anything worth caching/showing.
|
||||
fn has_display(&self) -> bool {
|
||||
self.slug.is_some() || self.pov.is_some() || self.goal.is_some()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Ordered markdown file names (relative to the workspace).
|
||||
files: Vec<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.
|
||||
buffer: 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,
|
||||
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,
|
||||
/// Index (into the currently displayed matches) of the word a right-click
|
||||
/// suggestion menu is open for, if any.
|
||||
spell_menu: Option<usize>,
|
||||
/// Whether the Mistral settings window is open.
|
||||
show_mistral_settings: bool,
|
||||
/// In-flight background plot-beat generation, if any.
|
||||
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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,
|
||||
files: Vec::new(),
|
||||
titles: HashMap::new(),
|
||||
title_input: String::new(),
|
||||
selected: None,
|
||||
buffer: String::new(),
|
||||
dirty: false,
|
||||
new_name: String::new(),
|
||||
rename_input: String::new(),
|
||||
pending_delete: false,
|
||||
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(),
|
||||
spell_menu: None,
|
||||
show_mistral_settings: false,
|
||||
beats_rx: None,
|
||||
beats_status: String::new(),
|
||||
beats_output: None,
|
||||
beats_source_stem: 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+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_lt();
|
||||
self.poll_settings_test();
|
||||
self.poll_spell();
|
||||
self.poll_beats();
|
||||
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.beats_output.is_some() {
|
||||
self.beats_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.config.save();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user