From 423e93c8940163c7dbe7e411b04c01b529a20d1b Mon Sep 17 00:00:00 2001 From: landon Date: Sat, 22 Aug 2026 09:02:19 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ --- src/app.rs | 3700 --------------------------------------- src/app/autocomplete.rs | 270 +++ src/app/beats.rs | 259 +++ src/app/editor.rs | 520 ++++++ src/app/file_list.rs | 256 +++ src/app/find.rs | 340 ++++ src/app/grammar.rs | 484 +++++ src/app/mod.rs | 437 +++++ src/app/spelling.rs | 137 ++ src/app/style.rs | 116 ++ src/app/ui.rs | 493 ++++++ src/app/util.rs | 54 + src/app/workspace.rs | 473 +++++ 13 files changed, 3839 insertions(+), 3700 deletions(-) delete mode 100644 src/app.rs create mode 100644 src/app/autocomplete.rs create mode 100644 src/app/beats.rs create mode 100644 src/app/editor.rs create mode 100644 src/app/file_list.rs create mode 100644 src/app/find.rs create mode 100644 src/app/grammar.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/spelling.rs create mode 100644 src/app/style.rs create mode 100644 src/app/ui.rs create mode 100644 src/app/util.rs create mode 100644 src/app/workspace.rs diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 6f9b505..0000000 --- a/src/app.rs +++ /dev/null @@ -1,3700 +0,0 @@ -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}; - -/// 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); - -/// 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, -} - -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 { - 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, - /// Ordered markdown file names (relative to the workspace). - files: Vec, - /// Per-file chapter title overrides (file name -> title). Missing/empty means - /// the title is derived automatically at export time. - titles: HashMap, - /// Editing buffer for the selected file's chapter title override. - title_input: String, - selected: Option, - /// 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, - /// 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, - /// 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, - /// Index (into the currently displayed matches) of the word a right-click - /// suggestion menu is open for, if any. - spell_menu: Option, - /// Whether the Mistral settings window is open. - show_mistral_settings: bool, - /// In-flight background plot-beat generation, if any. - beats_rx: Option>>, - /// 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, -} - -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 - } - - fn workspace(&self) -> &Path { - &self.config.workspace - } - - /// (Re)load the file list for the current workspace, creating the directory - /// if needed, and refresh git status. - fn open_workspace(&mut self) { - let ws = self.config.workspace.clone(); - if let Err(e) = std::fs::create_dir_all(&ws) { - self.status = format!("Cannot create workspace: {e}"); - return; - } - self.files = order::resolve_order(&ws); - self.titles = order::read_titles(&ws); - // Drop overrides for files that no longer exist. - self.titles.retain(|name, _| self.files.contains(name)); - self.detect_repo(&ws); - self.selected = None; - self.buffer.clear(); - self.dirty = false; - self.pending_delete = false; - self.clear_lt(); - self.session_start_counts = self.snapshot_counts(); - self.file_meta = self.snapshot_file_meta(); - self.rebuild_field_names(); - self.autocomplete = None; - if !self.files.is_empty() { - self.select(0); - } - self.persist_order(); - self.status = format!("{} file(s) in {}", self.files.len(), ws.display()); - } - - /// Work out the git situation for a freshly opened workspace. - /// - /// If the folder is itself a repository root it is adopted silently. If it - /// is not, but an enclosing parent directory is a git work tree, we stash - /// that parent in `pending_repo` and ask the user to confirm before using - /// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is - /// treated as having no repository. - fn detect_repo(&mut self, ws: &Path) { - self.pending_repo = None; - // A `.git` entry directly in the folder (dir, or a file for linked - // worktrees/submodules) means this folder is the repo root. - if ws.join(".git").exists() { - self.is_repo = true; - self.repo_root = Some(ws.to_path_buf()); - return; - } - match gitsync::repo_root(ws) { - // A parent directory is a repository — ask before adopting it. - Some(root) if root != ws => { - self.is_repo = false; - self.repo_root = None; - self.pending_repo = Some(root); - } - // `--show-toplevel` reported this very folder (shouldn't happen - // without a `.git` here, but treat it as an ordinary repo root). - Some(root) => { - self.is_repo = true; - self.repo_root = Some(root); - } - None => { - self.is_repo = false; - self.repo_root = None; - } - } - } - - /// Adopt the parent repository awaiting confirmation, using it for all git - /// operations on the current workspace. - fn adopt_pending_repo(&mut self) { - if let Some(root) = self.pending_repo.take() { - self.status = format!("Using git repository at {}", root.display()); - self.is_repo = true; - self.repo_root = Some(root); - } - } - - /// Decline the parent repository; the workspace stays without version - /// control (the user can still `Init git` to create a nested repo). - fn decline_pending_repo(&mut self) { - self.pending_repo = None; - self.is_repo = false; - self.repo_root = None; - self.status = "Not using the enclosing git repository".to_string(); - } - - /// Read every file and return its current on-disk word count. - fn snapshot_counts(&self) -> HashMap { - self.files - .iter() - .map(|name| { - let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - (name.clone(), count_words(&text)) - }) - .collect() - } - - /// Read every file and return its cached header info (slug/POV/goal/prose), - /// for files that carry anything worth showing in the list. - fn snapshot_file_meta(&self) -> HashMap { - self.files - .iter() - .filter_map(|name| { - let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - let meta = FileMeta::from_markdown(&text, &self.config.draft_marker); - meta.has_display().then(|| (name.clone(), meta)) - }) - .collect() - } - - fn persist_order(&self) { - let _ = order::write_order(self.workspace(), &self.files); - } - - fn persist_titles(&self) { - let _ = order::write_titles(self.workspace(), &self.titles); - } - - /// Store the title override for the selected file from `title_input`. - /// An empty value removes the override (falls back to the auto title). - fn set_title_for_current(&mut self) { - if let Some(idx) = self.selected { - if let Some(name) = self.files.get(idx).cloned() { - let trimmed = self.title_input.trim(); - if trimmed.is_empty() { - self.titles.remove(&name); - } else { - self.titles.insert(name, trimmed.to_string()); - } - self.persist_titles(); - } - } - } - - fn path_for(&self, name: &str) -> PathBuf { - self.workspace().join(name) - } - - /// Save the in-memory buffer to disk if it has unsaved changes. - fn save_current(&mut self) { - if let Some(idx) = self.selected { - if self.dirty { - if let Some(name) = self.files.get(idx).cloned() { - let path = self.path_for(&name); - match std::fs::write(&path, &self.buffer) { - Ok(_) => { - self.dirty = false; - self.status = format!("Saved {name}"); - // Keep the file-list cache (slug/POV/goal/prose) in step. - let meta = - FileMeta::from_markdown(&self.buffer, &self.config.draft_marker); - if meta.has_display() { - self.file_meta.insert(name, meta); - } else { - self.file_meta.remove(&name); - } - self.merge_field_names_from_buffer(); - } - Err(e) => self.status = format!("Save failed: {e}"), - } - } - } - } - } - - /// Discard any grammar-check results (they belong to a specific buffer). - fn clear_lt(&mut self) { - self.lt_matches.clear(); - self.lt_checked_text.clear(); - self.lt_status.clear(); - } - - fn select(&mut self, idx: usize) { - if self.selected == Some(idx) { - return; - } - self.save_current(); - self.clear_lt(); - // Matches from the previous file's buffer are meaningless now. - self.find_matches.clear(); - self.find_active = 0; - self.find_needs_refresh = true; - // Re-run the live spell check on the newly loaded buffer immediately. - self.spell_matches.clear(); - self.spell_checked_text.clear(); - self.spell_dirty = true; - self.spell_last_edit = None; - self.spell_menu = None; - if let Some(name) = self.files.get(idx) { - let path = self.path_for(name); - self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); - self.selected = Some(idx); - self.dirty = false; - self.pending_delete = false; - self.rename_input = Path::new(name) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - self.title_input = self.titles.get(name).cloned().unwrap_or_default(); - } - } - - fn create_file(&mut self) { - let mut stem = self.new_name.trim().to_string(); - if stem.is_empty() { - self.status = "Enter a name for the new file".to_string(); - return; - } - if stem.to_lowercase().ends_with(".md") { - stem.truncate(stem.len() - 3); - } - let name = format!("{stem}.md"); - let path = self.path_for(&name); - if path.exists() { - self.status = format!("{name} already exists"); - return; - } - let seed = format!("# {stem}\n\n"); - match std::fs::write(&path, seed) { - Ok(_) => { - self.files.push(name.clone()); - self.persist_order(); - let idx = self.files.len() - 1; - self.selected = None; // force reload of buffer - self.select(idx); - self.new_name.clear(); - self.status = format!("Created {name}"); - } - Err(e) => self.status = format!("Create failed: {e}"), - } - } - - fn delete_selected(&mut self) { - if let Some(idx) = self.selected { - if let Some(name) = self.files.get(idx).cloned() { - let path = self.path_for(&name); - match std::fs::remove_file(&path) { - Ok(_) => { - self.files.remove(idx); - self.titles.remove(&name); - self.session_start_counts.remove(&name); - self.file_meta.remove(&name); - self.persist_titles(); - self.persist_order(); - self.selected = None; - self.buffer.clear(); - self.dirty = false; - if !self.files.is_empty() { - self.select(idx.min(self.files.len() - 1)); - } - self.status = format!("Deleted {name}"); - } - Err(e) => self.status = format!("Delete failed: {e}"), - } - } - } - self.pending_delete = false; - } - - fn rename_selected(&mut self) { - let Some(idx) = self.selected else { return }; - let mut stem = self.rename_input.trim().to_string(); - if stem.to_lowercase().ends_with(".md") { - stem.truncate(stem.len() - 3); - } - if stem.is_empty() { - self.status = "Enter a new name".to_string(); - return; - } - let new_name = format!("{stem}.md"); - let Some(old_name) = self.files.get(idx).cloned() else { - return; - }; - if new_name == old_name { - return; - } - let new_path = self.path_for(&new_name); - if new_path.exists() { - self.status = format!("{new_name} already exists"); - return; - } - // Persist any pending edits under the old name first. - self.save_current(); - match std::fs::rename(self.path_for(&old_name), &new_path) { - Ok(_) => { - self.files[idx] = new_name.clone(); - if let Some(title) = self.titles.remove(&old_name) { - self.titles.insert(new_name.clone(), title); - self.persist_titles(); - } - if let Some(words) = self.session_start_counts.remove(&old_name) { - self.session_start_counts.insert(new_name.clone(), words); - } - if let Some(meta) = self.file_meta.remove(&old_name) { - self.file_meta.insert(new_name.clone(), meta); - } - self.persist_order(); - self.status = format!("Renamed to {new_name}"); - } - Err(e) => self.status = format!("Rename failed: {e}"), - } - } - - fn git_init(&mut self) { - // Initialising creates a repository in the workspace itself, which - // supersedes any enclosing repo we were about to ask about. - self.pending_repo = None; - let ws = self.workspace().to_path_buf(); - let outcome = gitsync::init(&ws); - self.git_log = outcome.log; - self.show_log = true; - self.is_repo = gitsync::is_repo(&ws); - self.repo_root = self.is_repo.then_some(ws); - self.status = if self.is_repo { - "Initialised git repository".to_string() - } else { - "git init failed (see log)".to_string() - }; - } - - fn git_sync(&mut self) { - self.save_current(); - self.persist_order(); - self.persist_titles(); - let msg = format!( - "Sync manuscript {}", - chrono_like_timestamp() - ); - let outcome = gitsync::sync(self.workspace(), &msg); - self.git_log = outcome.log; - self.show_log = true; - self.status = if outcome.ok { - "Sync complete".to_string() - } else { - "Sync finished with errors (see log)".to_string() - }; - } - - /// Digits to zero-pad a defaulted chapter number to: the width of the largest - /// chapter number when padding is enabled, otherwise 1 (no padding). - fn index_pad_width(&self) -> usize { - if self.config.zero_pad_index { - self.files.len().to_string().len().max(1) - } else { - 1 - } - } - - /// The title a chapter at `idx` would get without a manual override: its - /// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint - /// in the chapter-title field. - fn auto_title(&self, idx: usize, markdown: &str) -> String { - let header = crate::preprocess::parse(markdown, &self.config.draft_marker); - resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width()) - } - - /// The current file's word-count target (from its `Word Count Target:` - /// header) paired with its current prose word count, if a target is set. - /// Prose = the body below the draft marker, so header metadata isn't counted. - fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { - self.selected?; - let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker); - let goal = header.goal?; - Some((goal, count_words(&header.body))) - } - - fn export_odt(&mut self) { - self.save_current(); - let marker = self.config.draft_marker.clone(); - let pad_width = self.index_pad_width(); - let mut chapters = Vec::new(); - for (i, name) in self.files.iter().enumerate() { - let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - // Strip comments + the editorial header, and lift out any - // `# Title:` / `# Slug:` metadata. - let header = crate::preprocess::parse(&raw, &marker); - - // Title priority: manual override > `# Title:` metadata > the chapter's - // 1-based position (e.g. "3."). The body from `parse` is used as-is. - let title = resolve_chapter_title( - self.titles.get(name).map(String::as_str), - header.title.as_deref(), - i, - pad_width, - ); - chapters.push(Chapter { - title, - slug: header.slug.clone(), - markdown: header.body, - }); - } - let out = PathBuf::from(self.export_input.trim()); - if let Some(parent) = out.parent() { - let _ = std::fs::create_dir_all(parent); - } - match odt::export(&chapters, &out) { - Ok(_) => { - self.config.export_path = out.clone(); - self.config.save(); - self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display()); - } - Err(e) => self.status = format!("Export failed: {e}"), - } - } - - /// Open a native folder picker to choose the workspace directory. - fn browse_workspace(&mut self) { - let start = PathBuf::from(self.workspace_input.trim()); - let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder"); - if start.is_dir() { - dialog = dialog.set_directory(&start); - } - if let Some(path) = dialog.pick_folder() { - self.save_current(); - self.workspace_input = path.display().to_string(); - self.config.workspace = path; - self.config.save(); - self.open_workspace(); - } - } - - /// Open a native save dialog to choose the export `.odt` path. - fn browse_export(&mut self) { - let current = PathBuf::from(self.export_input.trim()); - let mut dialog = rfd::FileDialog::new() - .set_title("Choose export file") - .add_filter("OpenDocument Text", &["odt"]); - if let Some(parent) = current.parent().filter(|p| p.is_dir()) { - dialog = dialog.set_directory(parent); - } - let name = current - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("manuscript.odt"); - if let Some(mut path) = dialog.set_file_name(name).save_file() { - // Ensure the chosen path ends in .odt even if the user omitted it. - let has_odt = path - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("odt")); - if !has_odt { - path.set_extension("odt"); - } - self.export_input = path.display().to_string(); - self.config.export_path = path; - self.config.save(); - } - } - - /// Kick off a LanguageTool check of the current buffer on a background - /// thread. Results are delivered through `lt_rx` and picked up in `update`. - fn start_lt_check(&mut self, ctx: &egui::Context) { - if self.selected.is_none() { - self.lt_status = "Open a file to check".to_string(); - return; - } - if self.lt_rx.is_some() { - return; // a check is already running - } - if self.config.languagetool_host.trim().is_empty() { - self.lt_status = "Set a LanguageTool host in Settings first".to_string(); - self.show_lt_panel = true; - self.show_settings = true; - return; - } - let url = self.config.languagetool_base_url(); - let token = self.config.languagetool_token.clone(); - let mut language = self.config.languagetool_language.trim().to_string(); - if language.is_empty() { - language = "auto".to_string(); - } - let text = self.buffer.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - self.lt_rx = Some(rx); - self.lt_status = "Checking…".to_string(); - self.show_lt_panel = true; - let ctx = ctx.clone(); - std::thread::spawn(move || { - let result = crate::langtool::check(&url, &language, &token, &text); - let _ = tx.send((text, result)); - ctx.request_repaint(); - }); - } - - /// Ping the configured server with a sample sentence to confirm the settings - /// work, without disturbing the editor's current results. Used by the - /// Settings dialog's "Test connection" button. - fn test_lt_connection(&mut self, ctx: &egui::Context) { - if self.settings_test_rx.is_some() { - return; - } - let url = self.config.languagetool_base_url(); - let token = self.config.languagetool_token.clone(); - let language = { - let l = self.config.languagetool_language.trim(); - if l.is_empty() { "auto".to_string() } else { l.to_string() } - }; - let (tx, rx) = std::sync::mpsc::channel(); - self.settings_test_rx = Some(rx); - self.settings_test_status = "Testing…".to_string(); - let ctx = ctx.clone(); - std::thread::spawn(move || { - // A sentence with a deliberate error, so a working server returns ≥1 match. - let result = - crate::langtool::check(&url, &language, &token, "The team are ready to began."); - let _ = tx.send(result.map(|m| m.len())); - ctx.request_repaint(); - }); - } - - /// Pick up a finished "Test connection" result for the Settings dialog. - fn poll_settings_test(&mut self) { - let received = self - .settings_test_rx - .as_ref() - .and_then(|rx| rx.try_recv().ok()); - if let Some(result) = received { - self.settings_test_rx = None; - self.settings_test_status = match result { - Ok(_) => format!("✔ Connected to {}", self.config.languagetool_base_url()), - Err(e) => format!("✖ {e}"), - }; - } - } - - /// Pick a novel-proposal markdown file and generate three-act plot beats - /// from it with the Mistral API, in the background. Opens the settings window - /// instead if no API key is configured yet. - fn generate_plot_beats(&mut self, ctx: &egui::Context) { - if self.beats_rx.is_some() { - return; // a generation is already running - } - if self.config.mistral_api_key.trim().is_empty() { - self.show_mistral_settings = true; - self.beats_status = "Set your Mistral API key first (Settings ▸ Mistral).".to_string(); - self.beats_output = Some(String::new()); - return; - } - - let mut dialog = rfd::FileDialog::new() - .set_title("Choose a novel proposal (markdown)") - .add_filter("Markdown / text", &["md", "markdown", "txt"]); - if let Some(dir) = self.workspace().to_str() { - dialog = dialog.set_directory(dir); - } - let Some(path) = dialog.pick_file() else { - return; // cancelled - }; - - let proposal = match std::fs::read_to_string(&path) { - Ok(t) => t, - Err(e) => { - self.beats_status = format!("Could not read {}: {e}", path.display()); - self.beats_output = Some(String::new()); - return; - } - }; - if proposal.trim().is_empty() { - self.beats_status = "That file is empty — nothing to work from.".to_string(); - self.beats_output = Some(String::new()); - return; - } - self.beats_source_stem = path - .file_stem() - .and_then(|s| s.to_str()) - .map(str::to_string); - - let api_key = self.config.mistral_api_key.clone(); - let model = self.config.mistral_effective_model(); - let base = self.config.mistral_effective_base_url(); - let (tx, rx) = std::sync::mpsc::channel(); - self.beats_rx = Some(rx); - self.beats_status = "Generating plot beats…".to_string(); - self.beats_output = Some(String::new()); - let ctx = ctx.clone(); - std::thread::spawn(move || { - let result = crate::mistral::generate_beats(&api_key, &model, &base, &proposal); - let _ = tx.send(result); - ctx.request_repaint(); - }); - } - - /// Pick up a finished plot-beat generation and show it (or its error). - fn poll_beats(&mut self) { - let received = self.beats_rx.as_ref().and_then(|rx| rx.try_recv().ok()); - if let Some(result) = received { - self.beats_rx = None; - match result { - Ok(beats) => { - self.beats_status = "Done — review, then save or copy.".to_string(); - self.beats_output = Some(beats); - } - Err(e) => { - self.beats_status = format!("✖ {e}"); - // Keep the window open so the error stays visible. - self.beats_output.get_or_insert_with(String::new); - } - } - } - } - - /// Write the generated beats to a new ` — beats.md` file in the - /// workspace (dodging name collisions), add it to the list, and open it. - fn save_beats_as_file(&mut self, beats: &str) { - let stem = self - .beats_source_stem - .clone() - .unwrap_or_else(|| "plot".to_string()); - let mut name = format!("{stem} — beats.md"); - let mut n = 2; - while self.path_for(&name).exists() { - name = format!("{stem} — beats-{n}.md"); - n += 1; - } - let path = self.path_for(&name); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim()); - match std::fs::write(&path, contents) { - Ok(_) => { - if !self.files.contains(&name) { - self.files.push(name.clone()); - self.persist_order(); - } - if let Some(idx) = self.files.iter().position(|f| f == &name) { - self.selected = None; // force the buffer to reload - self.select(idx); - } - self.status = format!("Saved {name}"); - self.beats_output = None; // close the window; the file is now open - self.beats_status.clear(); - } - Err(e) => self.beats_status = format!("Save failed: {e}"), - } - } - - /// Floating window presenting the generated plot beats, with save / copy. - fn beats_window(&mut self, ctx: &egui::Context) { - let mut open = true; - let mut close = false; - let mut save = false; - let running = self.beats_rx.is_some(); - // Edit a local copy so the button row can borrow `self` mutably; persist - // any edits back into `beats_output` afterwards. - let mut text = self.beats_output.clone().unwrap_or_default(); - egui::Window::new("✨ Plot beats (Mistral)") - .open(&mut open) - .resizable(true) - .default_width(560.0) - .default_height(520.0) - .show(ctx, |ui| { - ui.horizontal(|ui| { - if running { - ui.spinner(); - } - if !self.beats_status.is_empty() { - ui.label(egui::RichText::new(&self.beats_status).weak()); - } - }); - ui.separator(); - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - ui.add( - egui::TextEdit::multiline(&mut text) - .desired_width(f32::INFINITY) - .desired_rows(20) - .font(egui::TextStyle::Monospace), - ); - }); - ui.separator(); - ui.horizontal(|ui| { - let have = !text.trim().is_empty(); - if ui - .add_enabled(have, egui::Button::new("💾 Save as new file")) - .clicked() - { - save = true; - } - if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() { - ui.output_mut(|o| o.copied_text = text.clone()); - self.beats_status = "Copied to clipboard.".to_string(); - } - if ui.button("Close").clicked() { - close = true; - } - }); - }); - - // Persist edits made in the text box (unless we're about to close/save). - if self.beats_output.is_some() { - self.beats_output = Some(text.clone()); - } - if save { - self.save_beats_as_file(&text); - } else if close || !open { - self.beats_output = None; - self.beats_status.clear(); - } - } - - /// Floating window for editing the Mistral API connection settings. - fn mistral_settings_window(&mut self, ctx: &egui::Context) { - let mut open = self.show_mistral_settings; - let mut close_clicked = false; - egui::Window::new("Mistral settings") - .open(&mut open) - .resizable(false) - .collapsible(false) - .show(ctx, |ui| { - let mut save_now = false; - egui::Grid::new("mistral_settings_grid") - .num_columns(2) - .spacing([10.0, 8.0]) - .show(ui, |ui| { - ui.label("API key:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.mistral_api_key) - .password(true) - .hint_text("from console.mistral.ai") - .desired_width(260.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); - - ui.label("Model:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.mistral_model) - .hint_text(crate::mistral::DEFAULT_MODEL) - .desired_width(260.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); - - ui.label("Base URL:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.mistral_base_url) - .hint_text(crate::mistral::DEFAULT_BASE_URL) - .desired_width(260.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); - }); - - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!( - "Endpoint: {}/v1/chat/completions", - self.config.mistral_effective_base_url() - )) - .weak() - .monospace(), - ); - ui.label( - egui::RichText::new( - "The key is stored in this app's config file in plain text. \ - The proposal you choose is sent to Mistral to generate the beats.", - ) - .small() - .weak(), - ); - ui.separator(); - if ui.button("Close").clicked() { - close_clicked = true; - } - if save_now { - self.config.save(); - } - }); - - let now_open = open && !close_clicked; - if self.show_mistral_settings && !now_open { - self.config.save(); - } - self.show_mistral_settings = now_open; - } - - /// Poll for a finished background check and store its results. - fn poll_lt(&mut self) { - let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok()); - if let Some((text, result)) = received { - self.lt_rx = None; - match result { - Ok(matches) => { - self.lt_status = match matches.len() { - 0 => "No issues found".to_string(), - 1 => "1 issue".to_string(), - n => format!("{n} issues"), - }; - self.lt_matches = matches; - self.lt_checked_text = text; - } - Err(e) => { - self.lt_matches.clear(); - self.lt_checked_text.clear(); - self.lt_status = e; - } - } - } - } - - /// Apply a LanguageTool suggestion to the buffer, then shift the remaining - /// matches so their highlights stay valid. - fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) { - // Only safe while the buffer still matches what was checked. - if self.buffer != self.lt_checked_text { - return; - } - if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) { - self.dirty = true; - self.find_needs_refresh = true; - self.lt_checked_text = self.buffer.clone(); - self.lt_status = match self.lt_matches.len() { - 0 => "No issues remaining".to_string(), - 1 => "1 issue".to_string(), - n => format!("{n} issues"), - }; - } - } - - /// Apply a spell-check suggestion to the buffer, keeping the remaining - /// misspelling underlines aligned. - fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) { - if self.buffer != self.spell_checked_text { - return; - } - if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) { - self.dirty = true; - self.find_needs_refresh = true; - // Keep these matches valid without forcing a full re-check. - self.spell_checked_text = self.buffer.clone(); - self.spell_status = match self.spell_matches.len() { - 0 => "No spelling issues".to_string(), - 1 => "1 spelling issue".to_string(), - n => format!("{n} spelling issues"), - }; - } - } - - /// Apply suggestion `rep_idx` of the match at `match_idx` from whichever - /// source currently owns the issues (LanguageTool or the spell checker). - fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) { - match self.issue_source() { - IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx), - IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx), - IssueSource::None => {} - } - } - - // ---- Spell check (offline) --------------------------------------------- - - /// (Re)load the dictionary named by `config.spell_language`, falling back to - /// the default and then to whatever is available. Resets the live results so - /// the buffer is re-checked against the new dictionary. - fn load_spell_dict(&mut self) { - let want = self.config.spell_language.clone(); - let entry = self - .spell_dicts - .iter() - .find(|d| d.id == want) - .or_else(|| { - self.spell_dicts - .iter() - .find(|d| d.id == crate::spell::DEFAULT_LANGUAGE) - }) - .or_else(|| self.spell_dicts.first()) - .cloned(); - - match entry { - Some(e) => match e.load() { - Ok(dict) => { - self.spell_dict = Some(Arc::new(dict)); - self.spell_status = format!("Dictionary: {}", e.id); - } - Err(err) => { - self.spell_dict = None; - self.spell_status = err; - } - }, - None => { - self.spell_dict = None; - self.spell_status = "No spelling dictionary available".to_string(); - } - } - self.spell_matches.clear(); - self.spell_checked_text.clear(); - self.spell_dirty = true; - self.spell_last_edit = None; - } - - /// Switch the active dictionary and persist the choice. - fn set_spell_language(&mut self, id: &str) { - if self.config.spell_language == id { - return; - } - self.config.spell_language = id.to_string(); - self.config.save(); - self.load_spell_dict(); - } - - /// True when LanguageTool results describe the current buffer exactly (so - /// they, rather than the live spell checker, own the underlines). - fn lt_is_current(&self) -> bool { - !self.lt_checked_text.is_empty() && self.buffer == self.lt_checked_text - } - - /// Which check currently owns the issues shown in the editor and panel. - fn issue_source(&self) -> IssueSource { - if self.lt_is_current() { - IssueSource::LanguageTool - } else if self.config.spell_check - && self.spell_dict.is_some() - && self.buffer == self.spell_checked_text - { - IssueSource::Spell - } else { - IssueSource::None - } - } - - /// The matches currently owning the editor's underlines (may be empty). - fn current_matches(&self) -> &[crate::langtool::Match] { - match self.issue_source() { - IssueSource::LanguageTool => &self.lt_matches, - IssueSource::Spell => &self.spell_matches, - IssueSource::None => &[], - } - } - - /// Index of the current match covering byte offset `byte`, if any. - fn match_index_at(&self, byte: usize) -> Option { - self.current_matches() - .iter() - .position(|m| byte >= m.start && byte < m.end) - } - - /// An owned snapshot of the current issues for the results panel. - fn issue_items(&self) -> (IssueSource, Vec) { - let source = self.issue_source(); - let (matches, checked) = match source { - IssueSource::LanguageTool => (&self.lt_matches, self.lt_checked_text.as_str()), - IssueSource::Spell => (&self.spell_matches, self.spell_checked_text.as_str()), - IssueSource::None => return (source, Vec::new()), - }; - let items = matches - .iter() - .enumerate() - .map(|(idx, m)| IssueItem { - idx, - snippet: checked.get(m.start..m.end).unwrap_or("").to_string(), - message: m.message.clone(), - spelling: m.spelling, - replacements: m.replacements.clone(), - }) - .collect(); - (source, items) - } - - /// The status line for the spelling checker given a match count. - fn spell_count_status(n: usize) -> String { - match n { - 0 => "No spelling issues".to_string(), - 1 => "1 spelling issue".to_string(), - n => format!("{n} spelling issues"), - } - } - - /// Pick up a finished background spell check. - fn poll_spell(&mut self) { - let received = self.spell_rx.as_ref().and_then(|rx| rx.try_recv().ok()); - if let Some((text, matches)) = received { - self.spell_rx = None; - if self.spell_dict.is_some() { - self.spell_status = Self::spell_count_status(matches.len()); - } - self.spell_matches = matches; - self.spell_checked_text = text; - } - } - - /// Start a background spell check of the current buffer if one is due: - /// enabled, a dictionary loaded, a file open, the buffer changed, no check - /// already running, and the debounce interval elapsed since the last edit. - fn maybe_start_spell_check(&mut self, ctx: &egui::Context) { - if !self.config.spell_check || self.selected.is_none() || !self.spell_dirty { - return; - } - // LanguageTool results already cover this exact buffer (spelling too), so - // don't spend effort on a spell pass that wouldn't be shown. - if self.lt_is_current() { - return; - } - let Some(dict) = self.spell_dict.clone() else { - return; - }; - if self.spell_rx.is_some() { - return; - } - // Debounce so we don't re-check on every keystroke. - const DEBOUNCE: Duration = Duration::from_millis(400); - if let Some(edited) = self.spell_last_edit { - let elapsed = edited.elapsed(); - if elapsed < DEBOUNCE { - ctx.request_repaint_after(DEBOUNCE - elapsed); - return; - } - } - self.spell_dirty = false; - let text = self.buffer.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - self.spell_rx = Some(rx); - let ctx = ctx.clone(); - std::thread::spawn(move || { - let matches = crate::spell::check(&dict, &text); - let _ = tx.send((text, matches)); - ctx.request_repaint(); - }); - } - - // ---- Find / replace ---------------------------------------------------- - - /// Open the find/replace bar, focusing the search field (`focus_search`) or - /// the replace field, and queue a match refresh and a scroll to the first hit. - fn open_find(&mut self, focus_search: bool) { - self.show_find = true; - self.find_focus = Some(focus_search); - self.find_needs_refresh = true; - self.find_scroll = true; - } - - /// Recompute the search matches for the current buffer and query, clamping - /// the active index and refreshing the status line. - fn refresh_find(&mut self) { - self.find_matches = find_matches(&self.buffer, &self.find_query, self.find_case_sensitive); - if self.find_active >= self.find_matches.len() { - self.find_active = 0; - } - self.update_find_status(); - self.find_needs_refresh = false; - } - - fn update_find_status(&mut self) { - self.find_status = if self.find_query.is_empty() { - String::new() - } else if self.find_matches.is_empty() { - "No matches".to_string() - } else { - format!("{} of {}", self.find_active + 1, self.find_matches.len()) - }; - } - - /// Move the active match forward (or backward), wrapping around, and ask the - /// editor to scroll it into view. - fn find_step(&mut self, forward: bool) { - let n = self.find_matches.len(); - if n == 0 { - return; - } - self.find_active = if forward { - (self.find_active + 1) % n - } else { - (self.find_active + n - 1) % n - }; - self.find_scroll = true; - self.update_find_status(); - } - - /// Replace the active match with the replacement text, then re-search so the - /// remaining highlights stay accurate. - fn replace_current(&mut self) { - let Some(&(s, e)) = self.find_matches.get(self.find_active) else { - return; - }; - if e > self.buffer.len() - || !self.buffer.is_char_boundary(s) - || !self.buffer.is_char_boundary(e) - { - return; - } - self.buffer.replace_range(s..e, &self.replace_query); - self.dirty = true; - // Editing invalidates any grammar-check offsets. - self.clear_lt(); - // Keep the same index so the "current" match becomes the next occurrence - // in document order; refresh recomputes and clamps it. - self.refresh_find(); - self.find_scroll = true; - self.status = "Replaced 1 occurrence".to_string(); - } - - /// Replace every match in the buffer in one pass. - fn replace_all_matches(&mut self) { - if self.find_query.is_empty() { - return; - } - let (new, count) = replace_all( - &self.buffer, - &self.find_query, - &self.replace_query, - self.find_case_sensitive, - ); - if count > 0 { - self.buffer = new; - self.dirty = true; - self.clear_lt(); - self.find_active = 0; - self.refresh_find(); - } - self.status = format!("Replaced {count} occurrence(s)"); - } - - fn reorder(&mut self, from: usize, mut to: usize) { - if from >= self.files.len() || from == to { - return; - } - // Remember the selected file by name so selection follows the move. - let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); - - let item = self.files.remove(from); - if from < to { - to -= 1; - } - to = to.min(self.files.len()); - self.files.insert(to, item); - self.persist_order(); - - if let Some(name) = selected_name { - self.selected = self.files.iter().position(|n| *n == name); - } - self.status = "Reordered".to_string(); - } - - // ---- UI ---------------------------------------------------------------- - - fn top_bar(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::top("top").show(ctx, |ui| { - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label("Workspace:"); - let resp = ui.add( - egui::TextEdit::singleline(&mut self.workspace_input) - .desired_width(300.0), - ); - if ui.button("📂").on_hover_text("Browse for workspace folder").clicked() { - self.browse_workspace(); - } - if ui.button("Open").clicked() - || (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - { - self.save_current(); - self.config.workspace = PathBuf::from(self.workspace_input.trim()); - self.config.save(); - self.open_workspace(); - } - ui.separator(); - if self.is_repo { - let hover = match &self.repo_root { - Some(root) if root.as_path() != self.workspace() => { - format!("Commit & push to the repository at {}", root.display()) - } - _ => "Commit & push this workspace's repository".to_string(), - }; - if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() { - self.git_sync(); - } - } else if ui.button("Init git").clicked() { - self.git_init(); - } - if ui.button("Log").clicked() { - self.show_log = !self.show_log; - } - }); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.label("Export:"); - ui.add( - egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0), - ); - if ui.button("📂").on_hover_text("Browse for export .odt file").clicked() { - self.browse_export(); - } - if ui.button("Export ODT").clicked() { - self.export_odt(); - } - ui.separator(); - if ui - .checkbox(&mut self.config.show_preview, "Preview") - .changed() - { - self.config.save(); - } - ui.separator(); - ui.label("Draft marker:") - .on_hover_text( - "Text above this line in each file (its header) is dropped on export; \ - # Title: / # Slug: lines become the chapter heading and caption. \ - Leave blank to export files whole.", - ); - let marker_resp = ui.add( - egui::TextEdit::singleline(&mut self.config.draft_marker) - .desired_width(140.0), - ); - if marker_resp.lost_focus() { - self.config.save(); - } - ui.separator(); - if ui - .checkbox(&mut self.config.zero_pad_index, "Zero-pad #") - .on_hover_text( - "When a chapter title defaults to its number, pad it with \ - leading zeros (e.g. 03. of 12).", - ) - .changed() - { - self.config.save(); - } - }); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.label("Grammar:").on_hover_text( - "Grammar & spelling via a LanguageTool server. \ - Configure it in Settings ▸ LanguageTool.", - ); - let checking = self.lt_rx.is_some(); - if ui - .add_enabled( - !checking && self.selected.is_some(), - egui::Button::new("✓ Check"), - ) - .on_hover_text("Check the current file's grammar and spelling") - .clicked() - { - self.start_lt_check(ctx); - } - if !self.lt_matches.is_empty() - && ui.button("Clear").on_hover_text("Clear check results").clicked() - { - self.clear_lt(); - } - if ui - .button("⚙") - .on_hover_text("LanguageTool settings") - .clicked() - { - self.show_settings = true; - } - if !self.lt_status.is_empty() { - ui.label(egui::RichText::new(&self.lt_status).weak()); - } - }); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.label("Spelling:").on_hover_text( - "Offline spell check (Hunspell dictionaries). Underlines misspellings \ - as you type when LanguageTool results aren't current; right-click a \ - word for suggestions.", - ); - if ui - .checkbox(&mut self.config.spell_check, "Check as I type") - .on_hover_text("Underline misspellings live using the dictionary below") - .changed() - { - self.config.save(); - if self.config.spell_check { - // Re-check the current buffer straight away. - self.spell_dirty = true; - self.spell_last_edit = None; - } - } - ui.label("Dictionary:"); - let current = self.config.spell_language.clone(); - let mut pick: Option = None; - egui::ComboBox::from_id_salt("spell_dict") - .selected_text(current.clone()) - .show_ui(ui, |ui| { - for d in &self.spell_dicts { - if ui - .selectable_label(current == d.id, &d.label) - .clicked() - { - pick = Some(d.id.clone()); - } - } - }); - if let Some(id) = pick { - self.set_spell_language(&id); - } - if !self.spell_status.is_empty() { - ui.label(egui::RichText::new(&self.spell_status).weak()); - } - }); - ui.add_space(4.0); - }); - - egui::TopBottomPanel::bottom("status").show(ctx, |ui| { - ui.add_space(2.0); - ui.horizontal(|ui| { - let dirty = if self.dirty { " • unsaved" } else { "" }; - ui.label(format!("{}{dirty}", self.status)); - - let goal = self.current_goal(); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if let Some(idx) = self.selected { - let name = &self.files[idx]; - let total = count_words(&self.buffer); - let baseline = - self.session_start_counts.get(name).copied().unwrap_or(0); - let delta = total as i64 - baseline as i64; - let sign = if delta < 0 { "-" } else { "+" }; - ui.label( - egui::RichText::new(format!( - "{} words · {sign}{} this session", - thousands(total), - thousands(delta.unsigned_abs() as usize), - )) - .weak(), - ) - .on_hover_text( - "Words in the current file · net change since this session opened", - ); - - // Progress toward the file's Word Count Target, if set. - if let Some((goal, prose)) = goal { - let (frac, color, text) = goal_progress(goal, prose); - ui.separator(); - ui.add( - egui::ProgressBar::new(frac) - .desired_width(150.0) - .fill(color) - .text(egui::RichText::new(text).small()), - ) - .on_hover_text( - "Prose words vs the file's “Word Count Target” header \ - (counts the body below the draft marker). Amber = under \ - target, green = in range, blue = over.", - ); - } - } - }); - }); - ui.add_space(2.0); - }); - } - - fn left_pane(&mut self, ctx: &egui::Context) { - egui::SidePanel::left("files") - .resizable(true) - .default_width(260.0) - .show(ctx, |ui| { - // The default theme renders unselected list rows fairly dim; bump - // the widget text colours so file names stay legible (especially in - // dark mode) without affecting the rest of the app. - boost_list_contrast(ui.visuals_mut()); - ui.add_space(4.0); - ui.heading("Files"); - ui.label( - egui::RichText::new("drag ⠿ to reorder") - .small() - .weak(), - ); - ui.separator(); - - let mut clicked: Option = None; - let mut from_to: Option<(usize, usize)> = None; - let pointer = ui.input(|i| i.pointer.interact_pos()); - - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .max_height(ui.available_height() - 120.0) - .show(ui, |ui| { - for idx in 0..self.files.len() { - let name = self.files[idx].clone(); - let selected = self.selected == Some(idx); - // The selected file's fields are read live from the - // buffer (so unsaved edits show); others come from the - // cache filled on open/save. - let meta = if selected { - FileMeta::from_markdown(&self.buffer, &self.config.draft_marker) - } else { - self.file_meta.get(&name).cloned().unwrap_or_default() - }; - let tooltip = meta.tooltip(); - let row = ui - .horizontal(|ui| { - ui.dnd_drag_source( - egui::Id::new(("dnd", &name)), - idx, - |ui| { - ui.label( - egui::RichText::new("⠿").monospace().weak(), - ); - }, - ); - // Reserve room on the right for a per-file - // word-count-target bar when the file sets one. - let bar_w = 44.0; - let reserve = if meta.goal.is_some() { bar_w + 6.0 } else { 0.0 }; - let label_w = (ui.available_width() - reserve).max(24.0); - let mut label = ui.add_sized( - [label_w, 20.0], - egui::SelectableLabel::new(selected, &name), - ); - if let Some(tooltip) = &tooltip { - label = label.on_hover_text(tooltip); - } - if label.clicked() { - clicked = Some(idx); - } - if let Some(goal) = meta.goal { - row_goal_bar(ui, bar_w, goal, meta.prose_words); - } - }) - .response; - - // Drop handling: is a dragged item hovering this row? - if let Some(_payload) = row.dnd_hover_payload::() { - let rect = row.rect; - let before = pointer - .map(|p| p.y < rect.center().y) - .unwrap_or(true); - let y = if before { rect.top() } else { rect.bottom() }; - ui.painter().hline( - rect.x_range(), - y, - egui::Stroke::new( - 2.0, - ui.visuals().selection.stroke.color, - ), - ); - if let Some(payload) = row.dnd_release_payload::() { - let target = if before { idx } else { idx + 1 }; - from_to = Some((*payload, target)); - } - } - } - }); - - if let Some(idx) = clicked { - self.select(idx); - } - if let Some((from, to)) = from_to { - self.reorder(from, to); - } - - ui.separator(); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut self.new_name) - .hint_text("new file name") - .desired_width(150.0), - ); - if ui.button("+ New").clicked() { - self.create_file(); - } - }); - - if self.selected.is_some() { - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut self.rename_input) - .hint_text("rename") - .desired_width(150.0), - ); - if ui.button("Rename").clicked() { - self.rename_selected(); - } - }); - ui.horizontal(|ui| { - if !self.pending_delete { - if ui.button("🗑 Delete").clicked() { - self.pending_delete = true; - } - } else { - ui.label("Delete file?"); - if ui - .button(egui::RichText::new("Yes").color(egui::Color32::RED)) - .clicked() - { - self.delete_selected(); - } - if ui.button("No").clicked() { - self.pending_delete = false; - } - } - }); - } - }); - } - - /// The application menu bar (File / View / Settings). - fn menu_bar(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::top("menubar").show(ctx, |ui| { - egui::menu::bar(ui, |ui| { - ui.menu_button("File", |ui| { - if ui.button("📂 Open workspace…").clicked() { - ui.close_menu(); - self.browse_workspace(); - } - if ui.button("Export ODT").clicked() { - ui.close_menu(); - self.export_odt(); - } - ui.separator(); - if ui.button("Quit").clicked() { - ui.close_menu(); - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - ui.menu_button("Edit", |ui| { - if ui.button("🔍 Find / Replace…").clicked() { - ui.close_menu(); - self.open_find(true); - } - }); - ui.menu_button("Tools", |ui| { - let busy = self.beats_rx.is_some(); - if ui - .add_enabled( - !busy, - egui::Button::new("✨ Generate plot beats (Mistral)…"), - ) - .clicked() - { - ui.close_menu(); - self.generate_plot_beats(ctx); - } - }); - ui.menu_button("View", |ui| { - if ui - .checkbox(&mut self.config.show_preview, "Preview pane") - .clicked() - { - self.config.save(); - } - ui.checkbox(&mut self.show_lt_panel, "Grammar panel"); - ui.checkbox(&mut self.show_log, "Git log"); - }); - ui.menu_button("Settings", |ui| { - if ui.button("LanguageTool…").clicked() { - ui.close_menu(); - self.show_settings = true; - } - if ui.button("Mistral…").clicked() { - ui.close_menu(); - self.show_mistral_settings = true; - } - }); - ui.menu_button("Help", |ui| { - if ui.button("📝 Markdown cheatsheet").clicked() { - ui.close_menu(); - self.show_cheatsheet = true; - } - }); - }); - }); - } - - /// Floating window for editing the LanguageTool connection settings. - fn settings_window(&mut self, ctx: &egui::Context) { - let mut open = self.show_settings; - let mut close_clicked = false; - egui::Window::new("LanguageTool settings") - .open(&mut open) - .resizable(false) - .collapsible(false) - .show(ctx, |ui| { - let mut save_now = false; - egui::Grid::new("lt_settings_grid") - .num_columns(2) - .spacing([10.0, 8.0]) - .show(ui, |ui| { - ui.label("Scheme:"); - egui::ComboBox::from_id_salt("lt_scheme") - .selected_text(self.config.languagetool_scheme.clone()) - .show_ui(ui, |ui| { - for s in ["http", "https"] { - if ui - .selectable_value( - &mut self.config.languagetool_scheme, - s.to_string(), - s, - ) - .clicked() - { - save_now = true; - } - } - }); - ui.end_row(); - - ui.label("Host / domain:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.languagetool_host) - .hint_text("localhost or lt.example.com") - .desired_width(230.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); - - ui.label("Port:"); - let r = ui.add( - egui::DragValue::new(&mut self.config.languagetool_port) - .speed(1.0) - .range(1..=65535), - ); - save_now |= r.drag_stopped() || r.lost_focus(); - ui.end_row(); - - ui.label("Token:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.languagetool_token) - .password(true) - .hint_text("optional — auth proxy / API key") - .desired_width(230.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); - - ui.label("Language:"); - let r = ui.add( - egui::TextEdit::singleline(&mut self.config.languagetool_language) - .hint_text("auto, en-US, de-DE…") - .desired_width(120.0), - ); - save_now |= r.lost_focus(); - ui.end_row(); - }); - - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!( - "Endpoint: {}/v2/check", - self.config.languagetool_base_url() - )) - .weak() - .monospace(), - ); - ui.separator(); - - ui.horizontal(|ui| { - let testing = self.settings_test_rx.is_some(); - if ui - .add_enabled(!testing, egui::Button::new("Test connection")) - .clicked() - { - self.config.save(); - self.test_lt_connection(ctx); - } - if ui.button("Close").clicked() { - close_clicked = true; - } - }); - if !self.settings_test_status.is_empty() { - ui.label(&self.settings_test_status); - } - - if save_now { - self.config.save(); - } - }); - - let now_open = open && !close_clicked; - if self.show_settings && !now_open { - // Window is closing — persist and reset its transient state. - self.config.save(); - self.settings_test_status.clear(); - } - self.show_settings = now_open; - } - - /// Bottom panel listing the current grammar/spelling issues with one-click - /// fixes. Shows LanguageTool results when they're fresh, otherwise the live - /// spell-check results. - fn lt_panel(&mut self, ctx: &egui::Context) { - let (source, items) = self.issue_items(); - egui::TopBottomPanel::bottom("ltpanel") - .resizable(true) - .default_height(190.0) - .show(ctx, |ui| { - let (title, status) = match source { - IssueSource::LanguageTool => ("Grammar & spelling", self.lt_status.clone()), - _ => ("Spelling", self.spell_status.clone()), - }; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(title).strong()); - if !status.is_empty() { - ui.label(egui::RichText::new(status).weak()); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Hide").clicked() { - self.show_lt_panel = false; - } - }); - }); - ui.separator(); - - if items.is_empty() { - let busy = self.lt_rx.is_some() || self.spell_rx.is_some(); - ui.label( - egui::RichText::new(if busy { "Checking…" } else { "No issues to show." }) - .weak(), - ); - return; - } - - let mut apply: Option<(usize, usize)> = None; - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - for item in &items { - ui.horizontal_wrapped(|ui| { - let col = if item.spelling { - egui::Color32::from_rgb(0xE0, 0x40, 0x40) - } else { - egui::Color32::from_rgb(0x3B, 0x82, 0xF6) - }; - ui.label(egui::RichText::new("●").color(col)); - if !item.snippet.is_empty() { - ui.label( - egui::RichText::new(format!("“{}”", item.snippet)).strong(), - ); - } - ui.label(&item.message); - }); - ui.horizontal_wrapped(|ui| { - ui.add_space(16.0); - if item.replacements.is_empty() { - ui.label( - egui::RichText::new("(no suggestion)").weak().italics(), - ); - } else { - for (j, rep) in item.replacements.iter().enumerate() { - if ui.button(egui::RichText::new(rep).small()).clicked() { - apply = Some((item.idx, j)); - } - } - } - }); - ui.separator(); - } - }); - - if let Some((i, j)) = apply { - self.apply_current_fix(i, j); - } - }); - } - - fn central(&mut self, ctx: &egui::Context) { - egui::CentralPanel::default().show(ctx, |ui| { - match self.selected { - Some(idx) => { - let name = self.files[idx].clone(); - ui.horizontal(|ui| { - ui.heading(&name); - if ui.button("💾 Save").clicked() { - self.dirty = true; // ensure save runs - self.save_current(); - } - ui.separator(); - ui.label("Zoom:"); - if ui - .add( - egui::Slider::new(&mut self.config.editor_zoom, -50.0..=200.0) - .suffix("%") - .step_by(5.0), - ) - .on_hover_text("Editor text size (0% = default)") - .changed() - { - self.config.save(); - } - if ui - .button("Reset") - .on_hover_text("Reset zoom to 0%") - .clicked() - { - self.config.editor_zoom = 0.0; - self.config.save(); - } - ui.separator(); - ui.label("Contrast:"); - if ui - .add( - egui::Slider::new( - &mut self.config.editor_text_contrast, - 0.0..=100.0, - ) - .suffix("%") - .step_by(5.0), - ) - .on_hover_text( - "Editor text contrast (0% = theme default; higher \ - brightens the text in dark mode)", - ) - .changed() - { - self.config.save(); - } - }); - ui.horizontal(|ui| { - ui.label("Chapter title:"); - let auto = self.auto_title(idx, &self.buffer); - let resp = ui.add( - egui::TextEdit::singleline(&mut self.title_input) - .hint_text(format!("auto: {auto}")) - .desired_width(320.0), - ); - if resp.changed() { - self.set_title_for_current(); - } - ui.label( - egui::RichText::new("used as the ODT chapter heading") - .small() - .weak(), - ); - }); - ui.separator(); - - if self.show_find { - self.find_bar(ui); - } - - if self.config.show_preview { - // Editor + read-only source-ish preview side by side. - let full = ui.available_size(); - ui.horizontal_top(|ui| { - let col_w = full.x / 2.0 - 6.0; - ui.allocate_ui(egui::vec2(col_w, full.y), |ui| { - self.editor(ui); - }); - ui.separator(); - ui.allocate_ui(egui::vec2(col_w, full.y), |ui| { - egui::ScrollArea::vertical() - .id_salt("preview") - .auto_shrink([false, false]) - .show(ui, |ui| { - render_preview(ui, &self.buffer); - }); - }); - }); - } else { - self.editor(ui); - } - } - None => { - ui.centered_and_justified(|ui| { - ui.label("Select a file on the left, or create a new one."); - }); - } - } - }); - } - - // ---- Header-field autocomplete ----------------------------------------- - - /// Rebuild the field-name list: the built-ins plus any `#+ Name:` fields - /// found in the header of every workspace file. - fn rebuild_field_names(&mut self) { - let mut names: Vec = - DEFAULT_FIELD_NAMES.iter().map(|s| s.to_string()).collect(); - for name in &self.files { - let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); - for f in header_field_names(&text, &self.config.draft_marker) { - if !names.iter().any(|n| n.eq_ignore_ascii_case(&f)) { - names.push(f); - } - } - } - names.sort_by_key(|s| s.to_lowercase()); - self.field_names = names; - } - - /// Fold any new header field names from the current buffer into the list, so - /// a field you just invented autocompletes without reopening the workspace. - fn merge_field_names_from_buffer(&mut self) { - let found = header_field_names(&self.buffer, &self.config.draft_marker); - let mut added = false; - for f in found { - if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) { - self.field_names.push(f); - added = true; - } - } - if added { - self.field_names.sort_by_key(|s| s.to_lowercase()); - } - } - - /// Insert the chosen field name at the caret, replacing the partial the user - /// had typed and leaving the caret after the inserted `": "`. - fn apply_field_completion(&mut self, ctx: &egui::Context, field: &str) { - let Some(ac) = self.autocomplete.take() else { - return; - }; - // The stored offsets are from the last layout; a nav-key or click didn't - // edit the buffer, but guard against anything unexpected. - if ac.cursor > self.buffer.len() - || ac.field_start > ac.cursor - || !self.buffer.is_char_boundary(ac.field_start) - || !self.buffer.is_char_boundary(ac.cursor) - { - return; - } - let replacement = format!("{field}: "); - self.buffer.replace_range(ac.field_start..ac.cursor, &replacement); - self.dirty = true; - self.find_needs_refresh = true; - self.spell_dirty = true; - self.spell_last_edit = Some(Instant::now()); - - // Put the caret just after the inserted "Field: " and refocus the editor. - let new_cursor_byte = ac.field_start + replacement.len(); - let char_idx = self.buffer[..new_cursor_byte].chars().count(); - let id = egui::Id::new(EDITOR_ID); - if let Some(mut state) = egui::widgets::text_edit::TextEditState::load(ctx, id) { - state.cursor.set_char_range(Some(egui::text::CCursorRange::one( - egui::text::CCursor::new(char_idx), - ))); - state.store(ctx, id); - } - ctx.memory_mut(|m| m.request_focus(id)); - ctx.request_repaint(); - } - - fn editor(&mut self, ui: &mut egui::Ui) { - // Base monospace size scaled by the zoom percentage. `desired_width` stays - // infinite and the scroll area is vertical-only, so text wraps to the pane - // width at any zoom (no horizontal scrolling). - let base = ui - .style() - .text_styles - .get(&egui::TextStyle::Monospace) - .map(|f| f.size) - .unwrap_or(12.0); - let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0); - - // Editor text colour, brightened toward max contrast by the contrast slider. - let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast); - - // Underline whichever check currently owns the buffer's issues - // (LanguageTool when fresh, otherwise the live spell checker). - let ranges: Vec<(usize, usize, bool)> = self - .current_matches() - .iter() - .map(|m| (m.start, m.end, m.spelling)) - .collect(); - - // Search-match highlights (all matches shaded, the active one stronger). - let find_ranges: Vec<(usize, usize)> = if self.show_find { - self.find_matches.clone() - } else { - Vec::new() - }; - let find_active = if self.show_find { - Some(self.find_active) - } else { - None - }; - - egui::ScrollArea::vertical() - .id_salt("editor") - .auto_shrink([false, false]) - .show(ui, |ui| { - let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { - let job = build_editor_job( - text, - size, - text_color, - &ranges, - &find_ranges, - find_active, - wrap_width, - ); - ui.fonts(|f| f.layout_job(job)) - }; - // Autocomplete: intercept navigation keys before the editor - // consumes them, acting on last frame's popup state. - let mut accept_field: Option = None; - let mut dismiss = false; - if let Some(ac) = self.autocomplete.as_mut() { - ui.input_mut(|i| { - if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) { - ac.selected = (ac.selected + 1) % ac.matches.len(); - } else if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) { - ac.selected = (ac.selected + ac.matches.len() - 1) % ac.matches.len(); - } else if i.consume_key(egui::Modifiers::NONE, egui::Key::Tab) - || i.consume_key(egui::Modifiers::NONE, egui::Key::Enter) - { - accept_field = ac.matches.get(ac.selected).cloned(); - } else if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { - dismiss = true; - } - }); - } - if dismiss { - // Remember the dismissed partial so it doesn't immediately reopen. - self.ac_dismissed = self.autocomplete.take().map(|a| a.partial); - } - if let Some(field) = accept_field { - self.apply_field_completion(ui.ctx(), &field); - } - - let output = egui::TextEdit::multiline(&mut self.buffer) - .id(egui::Id::new(EDITOR_ID)) - .code_editor() - .desired_width(f32::INFINITY) - .desired_rows(30) - .layouter(&mut layouter) - .show(ui); - if output.response.changed() { - self.dirty = true; - // The buffer changed, so search matches and the live spell - // check both need refreshing (the latter debounced). - self.find_needs_refresh = true; - self.spell_dirty = true; - self.spell_last_edit = Some(Instant::now()); - } - // Markdown formatting hotkeys, applied to the current selection - // while the editor is focused (Ctrl/Cmd + B / I / E / K, and - // Ctrl/Cmd+Shift+X for strikethrough). - if output.response.has_focus() { - if let Some(fmt) = ui.input_mut(detect_format_hotkey) { - let sel = output - .cursor_range - .map(|cr| cr.as_sorted_char_range()) - .unwrap_or(0..0); - let (new_text, new_sel) = apply_format(&self.buffer, sel, fmt); - self.buffer = new_text; - self.dirty = true; - // Restore the caret/selection around the change. - let mut state = output.state; - state.cursor.set_char_range(Some(egui::text::CCursorRange::two( - egui::text::CCursor::new(new_sel.start), - egui::text::CCursor::new(new_sel.end), - ))); - state.store(ui.ctx(), output.response.id); - ui.ctx().request_repaint(); - } - } - - // Scroll the active search match into view when requested (on - // open, Next/Prev, Enter, or after a replace). - if self.find_scroll { - if let Some(&(s, _)) = self.find_matches.get(self.find_active) { - let char_idx = self - .buffer - .get(..s) - .map(|p| p.chars().count()) - .unwrap_or(0); - let local = - output.galley.pos_from_ccursor(egui::text::CCursor::new(char_idx)); - let onscreen = local.translate(output.galley_pos.to_vec2()); - ui.scroll_to_rect(onscreen, Some(egui::Align::Center)); - } - self.find_scroll = false; - } - - // Right-click a misspelling → a menu of suggested corrections. - if output.response.secondary_clicked() { - self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| { - let local = pos - output.galley_pos; - let cursor = output.galley.cursor_from_pos(local); - let byte = char_to_byte(&self.buffer, cursor.ccursor.index); - self.match_index_at(byte) - }); - } - // Snapshot the target's suggestions (owned) so the menu closure - // doesn't borrow self; compute them on demand if the background - // check hadn't produced any for this word. - let menu: Option<(usize, Vec)> = self.spell_menu.and_then(|i| { - let m = self.current_matches().get(i)?; - let mut reps = m.replacements.clone(); - if reps.is_empty() { - if let (Some(dict), Some(word)) = - (&self.spell_dict, self.buffer.get(m.start..m.end)) - { - reps = crate::spell::suggestions(dict, word); - } - } - Some((i, reps)) - }); - let mut chosen: Option<(usize, usize)> = None; - output.response.context_menu(|ui| { - match &menu { - Some((i, reps)) if !reps.is_empty() => { - ui.label(egui::RichText::new("Suggestions").strong()); - for (j, rep) in reps.iter().enumerate() { - if ui.button(rep).clicked() { - chosen = Some((*i, j)); - ui.close_menu(); - } - } - } - Some(_) => { - ui.label(egui::RichText::new("No suggestions").weak()); - } - None => { - ui.label(egui::RichText::new("No spelling issue here").weak()); - } - } - }); - if let Some((i, j)) = chosen { - self.apply_current_fix(i, j); - self.spell_menu = None; - } - - // Recompute the header-field autocomplete from the caret so the - // popup tracks what the user is typing (only for a collapsed - // caret in the focused editor). - if output.response.has_focus() { - let caret = output.cursor_range.and_then(|cr| { - let r = cr.as_sorted_char_range(); - (r.start == r.end).then_some(r.end) - }); - let mut next = None; - let mut still_dismissed = false; - if let Some(caret_char) = caret { - let caret_byte = char_to_byte(&self.buffer, caret_char); - if let Some((field_start, partial)) = header_completion_context( - &self.buffer, - caret_byte, - &self.config.draft_marker, - ) { - let partial_l = partial.to_lowercase(); - if self.ac_dismissed.as_deref() == Some(partial_l.as_str()) { - // Esc-dismissed for exactly this partial; stay closed. - still_dismissed = true; - } else { - let matches = field_matches(&self.field_names, &partial); - if !matches.is_empty() { - // Keep the highlight while typing the same prefix. - let selected = match &self.autocomplete { - Some(prev) - if prev.partial == partial_l - && prev.selected < matches.len() => - { - prev.selected - } - _ => 0, - }; - let caret_rect = output - .galley - .pos_from_ccursor(egui::text::CCursor::new(caret_char)); - let anchor = caret_rect.left_bottom() - + output.galley_pos.to_vec2() - + egui::vec2(0.0, 2.0); - next = Some(Autocomplete { - matches, - selected, - partial: partial_l, - field_start, - cursor: caret_byte, - anchor, - }); - } - } - } - } - self.autocomplete = next; - if !still_dismissed { - self.ac_dismissed = None; - } - } else { - self.autocomplete = None; - } - }); - - // Header-field autocomplete popup, floating just below the caret. - let mut clicked_field: Option = None; - if let Some(ac) = &self.autocomplete { - egui::Area::new(egui::Id::new("field_autocomplete")) - .order(egui::Order::Foreground) - .fixed_pos(ac.anchor) - .constrain(true) - .show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_max_width(260.0); - for (i, m) in ac.matches.iter().enumerate() { - if ui.selectable_label(i == ac.selected, m).clicked() { - clicked_field = Some(m.clone()); - } - } - }); - }); - } - if let Some(field) = clicked_field { - self.apply_field_completion(ui.ctx(), &field); - } - } - - /// The find/replace bar shown above the editor when `show_find` is set. - fn find_bar(&mut self, ui: &mut egui::Ui) { - egui::Frame::group(ui.style()).show(ui, |ui| { - // Find row. - ui.horizontal(|ui| { - ui.label("Find: "); - let resp = ui.add( - egui::TextEdit::singleline(&mut self.find_query) - .desired_width(220.0) - .hint_text("search text"), - ); - if self.find_focus == Some(true) { - resp.request_focus(); - self.find_focus = None; - } - if resp.changed() { - self.find_active = 0; - self.refresh_find(); - self.find_scroll = true; - } - // Enter jumps to the next match, Shift+Enter to the previous one; - // keep the field focused so the key can be repeated. - if resp.lost_focus() { - let (enter, shift) = - ui.input(|i| (i.key_pressed(egui::Key::Enter), i.modifiers.shift)); - if enter { - self.find_step(!shift); - self.find_focus = Some(true); - } - } - let have = !self.find_matches.is_empty(); - if ui - .add_enabled(have, egui::Button::new("▲")) - .on_hover_text("Previous match (Shift+Enter)") - .clicked() - { - self.find_step(false); - } - if ui - .add_enabled(have, egui::Button::new("▼")) - .on_hover_text("Next match (Enter)") - .clicked() - { - self.find_step(true); - } - if ui - .checkbox(&mut self.find_case_sensitive, "Aa") - .on_hover_text("Match case") - .changed() - { - self.find_active = 0; - self.refresh_find(); - } - if !self.find_status.is_empty() { - ui.label(egui::RichText::new(&self.find_status).weak()); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("✕").on_hover_text("Close (Esc)").clicked() { - self.show_find = false; - } - }); - }); - // Replace row. - ui.horizontal(|ui| { - ui.label("Replace:"); - let resp = ui.add( - egui::TextEdit::singleline(&mut self.replace_query) - .desired_width(220.0) - .hint_text("replacement text"), - ); - if self.find_focus == Some(false) { - resp.request_focus(); - self.find_focus = None; - } - let have = !self.find_matches.is_empty(); - if ui - .add_enabled(have, egui::Button::new("Replace")) - .on_hover_text("Replace the current match, then move to the next") - .clicked() - { - self.replace_current(); - } - if ui - .add_enabled(have, egui::Button::new("Replace all")) - .on_hover_text("Replace every match in this file") - .clicked() - { - self.replace_all_matches(); - } - }); - }); - ui.add_space(4.0); - } - - /// Ask the user whether to adopt a git repository found in a parent - /// directory of the freshly opened workspace. - fn repo_prompt(&mut self, ctx: &egui::Context) { - let Some(root) = self.pending_repo.clone() else { - return; - }; - let mut adopt = false; - let mut decline = false; - egui::Window::new("Use enclosing git repository?") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.label( - "This folder isn't a git repository, but one was found in a \ - parent directory:", - ); - ui.add_space(4.0); - ui.label(egui::RichText::new(root.display().to_string()).strong()); - ui.add_space(4.0); - ui.label( - "Use it for version control (Sync commits and pushes to this \ - repo)? Otherwise the workspace is left without git; you can \ - still Init a separate repository here.", - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Use this repository").clicked() { - adopt = true; - } - if ui.button("No, keep separate").clicked() { - decline = true; - } - }); - }); - if adopt { - self.adopt_pending_repo(); - } else if decline { - self.decline_pending_repo(); - } - } -} - -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(); - } -} - -/// Build the editor's laid-out text, underlining grammar/spelling matches and -/// shading search matches. -/// -/// `grammar` are `(start_byte, end_byte, is_spelling)` triples: spelling issues -/// get a red underline, grammar/style issues a blue one. `find` are -/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent -/// highlight; the one at index `find_active` (if any) is shaded more strongly. -fn build_editor_job( - text: &str, - size: f32, - color: egui::Color32, - grammar: &[(usize, usize, bool)], - find: &[(usize, usize)], - find_active: Option, - wrap_width: f32, -) -> egui::text::LayoutJob { - use egui::text::{LayoutJob, TextFormat}; - let font_id = egui::FontId::monospace(size); - let mut job = LayoutJob::default(); - job.wrap.max_width = wrap_width; - - if grammar.is_empty() && find.is_empty() { - job.append(text, 0.0, TextFormat::simple(font_id, color)); - return job; - } - - // Split the text at every match boundary (grammar and find), then format - // each run according to which ranges cover it. - let mut points: Vec = vec![0, text.len()]; - for &(s, e, _) in grammar { - points.push(s); - points.push(e); - } - for &(s, e) in find { - points.push(s); - points.push(e); - } - points.retain(|&p| p <= text.len() && text.is_char_boundary(p)); - points.sort_unstable(); - points.dedup(); - - let spell_color = egui::Color32::from_rgb(0xE0, 0x40, 0x40); - let grammar_color = egui::Color32::from_rgb(0x3B, 0x82, 0xF6); - // Translucent so the highlight reads in both light and dark themes. - let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70); - let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150); - let width = (size * 0.08).max(1.5); - - for w in points.windows(2) { - let (a, b) = (w[0], w[1]); - if a >= b { - continue; - } - let mut fmt = TextFormat::simple(font_id.clone(), color); - if let Some(&(_, _, spelling)) = - grammar.iter().find(|&&(s, e, _)| s < e && a >= s && b <= e) - { - let c = if spelling { spell_color } else { grammar_color }; - fmt.underline = egui::Stroke::new(width, c); - } - if let Some(i) = find - .iter() - .position(|&(s, e)| s < e && a >= s && b <= e) - { - fmt.background = if Some(i) == find_active { - find_active_bg - } else { - find_bg - }; - } - job.append(&text[a..b], 0.0, fmt); - } - job -} - -/// Fraction each base font size is enlarged, applied once at startup so every -/// panel's text is slightly bigger without changing layout maths elsewhere. -const FONT_SCALE: f32 = 1.12; - -/// Configure the shared context style once at startup: raise the contrast of -/// *all* UI text (menus, top bar, buttons, panels, headings — everything, not -/// just the file list and editor) and enlarge every base font size slightly. -/// -/// The default dark theme uses fairly dim greys (~140) for body text, the main -/// legibility complaint; we pull each widget state's foreground toward white in -/// dark mode / black in light mode. Explicitly coloured spans (grammar -/// underlines, the cheatsheet's syntax colour) set their own colours and are -/// unaffected. The per-pane boosts (`boost_list_contrast`, the editor contrast -/// slider) still layer on top for their specific widgets. -fn apply_global_style(ctx: &egui::Context) { - let mut style = (*ctx.style()).clone(); - - // Enlarge every text style (Body, Button, Heading, Monospace, Small). - for font in style.text_styles.values_mut() { - font.size = (font.size * FONT_SCALE).round(); - } - - let v = &mut style.visuals; - let target = if v.dark_mode { - egui::Color32::WHITE - } else { - egui::Color32::BLACK - }; - let w = &mut v.widgets; - w.noninteractive.fg_stroke.color = - lerp_color(w.noninteractive.fg_stroke.color, target, 0.55); - w.inactive.fg_stroke.color = lerp_color(w.inactive.fg_stroke.color, target, 0.45); - w.hovered.fg_stroke.color = lerp_color(w.hovered.fg_stroke.color, target, 0.35); - w.active.fg_stroke.color = lerp_color(w.active.fg_stroke.color, target, 0.35); - w.open.fg_stroke.color = lerp_color(w.open.fg_stroke.color, target, 0.45); - - ctx.set_style(style); -} - -/// Raise the text contrast of list-style widgets in the current `ui`, so file -/// names in the left pane stay legible against the panel background. Only the -/// widget foreground colours are touched; layout and the rest of the app are -/// unaffected (the change is scoped to the panel that calls this). -fn boost_list_contrast(visuals: &mut egui::Visuals) { - if visuals.dark_mode { - // Unselected rows, hover, and the pressed state → toward white. - visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(235); - visuals.widgets.hovered.fg_stroke.color = egui::Color32::WHITE; - visuals.widgets.active.fg_stroke.color = egui::Color32::WHITE; - // Selected-row text (SelectableLabel uses selection.stroke when selected). - visuals.selection.stroke.color = egui::Color32::WHITE; - } else { - visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(20); - visuals.widgets.hovered.fg_stroke.color = egui::Color32::BLACK; - visuals.widgets.active.fg_stroke.color = egui::Color32::BLACK; - visuals.selection.stroke.color = egui::Color32::WHITE; - } -} - -/// The editor's text colour, interpolated from the theme's default toward the -/// maximum-contrast colour (white in dark mode, black in light mode) by -/// `contrast` percent (0 = theme default, 100 = full contrast). -fn editor_text_color(visuals: &egui::Visuals, contrast: f32) -> egui::Color32 { - let base = visuals.text_color(); - let target = if visuals.dark_mode { - egui::Color32::WHITE - } else { - egui::Color32::BLACK - }; - lerp_color(base, target, contrast / 100.0) -} - -/// Linearly interpolate between two colours in gamma (sRGB byte) space. -/// `t` is clamped to `[0, 1]`; `t = 0` yields `a`, `t = 1` yields `b`. -fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 { - let t = t.clamp(0.0, 1.0); - let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8; - egui::Color32::from_rgb( - mix(a.r(), b.r()), - mix(a.g(), b.g()), - mix(a.b(), b.b()), - ) -} - -/// An inline markdown formatting action triggered by an editor hotkey. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Fmt { - Bold, - Italic, - Code, - Strike, - Link, -} - -impl Fmt { - /// The `(prefix, suffix)` markers wrapped around the selection. - fn markers(self) -> (&'static str, &'static str) { - match self { - Fmt::Bold => ("**", "**"), - Fmt::Italic => ("*", "*"), - Fmt::Code => ("`", "`"), - Fmt::Strike => ("~~", "~~"), - Fmt::Link => ("[", "](url)"), - } - } -} - -/// Map a formatting hotkey (if pressed this frame) to its action, consuming the -/// key event so nothing else reacts to it. Cmd+Shift+X is checked first because -/// `consume_key` ignores an *extra* Shift, so a bare Cmd+X test would swallow it. -fn detect_format_hotkey(input: &mut egui::InputState) -> Option { - use egui::{Key, Modifiers}; - let cmd = Modifiers::COMMAND; - if input.consume_key(cmd | Modifiers::SHIFT, Key::X) { - return Some(Fmt::Strike); - } - if input.consume_key(cmd, Key::B) { - return Some(Fmt::Bold); - } - if input.consume_key(cmd, Key::I) { - return Some(Fmt::Italic); - } - if input.consume_key(cmd, Key::E) { - return Some(Fmt::Code); - } - if input.consume_key(cmd, Key::K) { - return Some(Fmt::Link); - } - None -} - -/// Byte offset of the `char_idx`-th character in `s` (or `s.len()` if past the end). -fn char_to_byte(s: &str, char_idx: usize) -> usize { - s.char_indices() - .nth(char_idx) - .map(|(b, _)| b) - .unwrap_or(s.len()) -} - -/// Apply an inline-format action to `text` given a selection in *character* -/// indices, returning the new text and the new selection (also character indices). -/// -/// Symmetric markers toggle: if the selection is already wrapped — markers just -/// inside it, or immediately surrounding it — the markers are removed; otherwise -/// they are added (an empty selection just drops the markers with the caret -/// between them). A link wraps the selection as the link text and selects the -/// `url` placeholder so it can be replaced. -fn apply_format( - text: &str, - sel: std::ops::Range, - fmt: Fmt, -) -> (String, std::ops::Range) { - let (prefix, suffix) = fmt.markers(); - let start = sel.start.min(sel.end); - let end = sel.start.max(sel.end); - let b_start = char_to_byte(text, start); - let b_end = char_to_byte(text, end); - let selected = &text[b_start..b_end]; - let plen = prefix.chars().count(); - let slen = suffix.chars().count(); - - if fmt == Fmt::Link { - let new = format!( - "{}{}{}{}{}", - &text[..b_start], prefix, selected, suffix, &text[b_end..] - ); - // The url placeholder sits after "](" (2 chars) inside the suffix. - let url_start = start + plen + selected.chars().count() + 2; - return (new, url_start..url_start + 3); - } - - // Toggle: markers immediately inside the selection. - if selected.starts_with(prefix) - && selected.ends_with(suffix) - && selected.len() >= prefix.len() + suffix.len() - { - let inner = &selected[prefix.len()..selected.len() - suffix.len()]; - let new = format!("{}{}{}", &text[..b_start], inner, &text[b_end..]); - return (new, start..(end - plen - slen)); - } - - // Toggle: markers immediately surrounding the selection. - if text[..b_start].ends_with(prefix) && text[b_end..].starts_with(suffix) { - let new = format!( - "{}{}{}", - &text[..b_start - prefix.len()], - selected, - &text[b_end + suffix.len()..] - ); - return (new, (start - plen)..(end - plen)); - } - - // Wrap. - let new = format!( - "{}{}{}{}{}", - &text[..b_start], prefix, selected, suffix, &text[b_end..] - ); - if start == end { - (new, (start + plen)..(start + plen)) - } else { - (new, (start + plen)..(end + plen)) - } -} - -/// Find every non-overlapping occurrence of `needle` in `haystack`, returning -/// their `(start_byte, end_byte)` ranges in document order. An empty needle -/// matches nothing. When `case_sensitive` is false, matching is done on the -/// Unicode-lowercased text while the returned offsets index the original. -fn find_matches(haystack: &str, needle: &str, case_sensitive: bool) -> Vec<(usize, usize)> { - if needle.is_empty() { - return Vec::new(); - } - if case_sensitive { - let mut out = Vec::new(); - let mut from = 0; - while let Some(pos) = haystack[from..].find(needle) { - let s = from + pos; - let e = s + needle.len(); - out.push((s, e)); - // Advance past this match (at least one byte to guarantee progress). - from = e.max(s + 1); - } - return out; - } - - // Case-insensitive: search the lowercased haystack, mapping match offsets - // back to the original string's byte positions. - let (lower, map) = lowercased_with_map(haystack); - let low_needle = needle.to_lowercase(); - let mut out = Vec::new(); - let mut from = 0; - while let Some(pos) = lower[from..].find(&low_needle) { - let ls = from + pos; - let le = ls + low_needle.len(); - out.push((map[ls], map[le])); - from = le.max(ls + 1); - } - out -} - -/// Lowercase `s`, returning the lowercased string plus a map from each byte -/// index in it (including the terminal `len`) back to the byte index in `s` -/// where the originating character began. Used to translate case-insensitive -/// match offsets back onto the original text. -fn lowercased_with_map(s: &str) -> (String, Vec) { - let mut lower = String::with_capacity(s.len()); - let mut map = Vec::with_capacity(s.len() + 1); - for (byte, ch) in s.char_indices() { - for lc in ch.to_lowercase() { - let mut buf = [0u8; 4]; - let encoded = lc.encode_utf8(&mut buf); - for _ in 0..encoded.len() { - map.push(byte); - } - lower.push_str(encoded); - } - } - map.push(s.len()); - (lower, map) -} - -/// Replace every non-overlapping match of `needle` in `text` with `replacement`, -/// returning the new text and the number of replacements made. -fn replace_all( - text: &str, - needle: &str, - replacement: &str, - case_sensitive: bool, -) -> (String, usize) { - let matches = find_matches(text, needle, case_sensitive); - if matches.is_empty() { - return (text.to_string(), 0); - } - let mut out = String::with_capacity(text.len()); - let mut last = 0; - for &(s, e) in &matches { - out.push_str(&text[last..s]); - out.push_str(replacement); - last = e; - } - out.push_str(&text[last..]); - (out, matches.len()) -} - -/// Very small markdown-ish preview (headings emphasised, everything else plain). -fn render_preview(ui: &mut egui::Ui, markdown: &str) { - use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; - let parser = Parser::new_ext(markdown, Options::ENABLE_STRIKETHROUGH); - let mut heading: Option = None; - let mut line = String::new(); - let mut bold = false; - let mut italic = false; - - let flush = |ui: &mut egui::Ui, line: &mut String, heading: &mut Option| { - if line.trim().is_empty() { - line.clear(); - *heading = None; - return; - } - let text = line.clone(); - match heading { - Some(HeadingLevel::H1) => { - ui.label(egui::RichText::new(text).size(22.0).strong()); - } - Some(HeadingLevel::H2) => { - ui.label(egui::RichText::new(text).size(18.0).strong()); - } - Some(_) => { - ui.label(egui::RichText::new(text).size(15.0).strong()); - } - None => { - ui.label(text); - } - } - line.clear(); - *heading = None; - }; - - for event in parser { - match event { - Event::Start(Tag::Heading { level, .. }) => heading = Some(level), - Event::End(TagEnd::Heading(_)) => flush(ui, &mut line, &mut heading), - Event::End(TagEnd::Paragraph) => flush(ui, &mut line, &mut heading), - Event::Start(Tag::Item) => line.push_str("• "), - Event::End(TagEnd::Item) => flush(ui, &mut line, &mut heading), - Event::Start(Tag::Strong) => bold = true, - Event::End(TagEnd::Strong) => bold = false, - Event::Start(Tag::Emphasis) => italic = true, - Event::End(TagEnd::Emphasis) => italic = false, - Event::Text(t) | Event::Code(t) => { - let _ = (bold, italic); - line.push_str(&t); - } - Event::SoftBreak | Event::HardBreak => line.push(' '), - Event::Rule => { - ui.separator(); - } - _ => {} - } - } - flush(ui, &mut line, &mut heading); -} - -/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the -/// `# Title:` header value, otherwise the chapter's 1-based position followed by -/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding). -fn resolve_chapter_title( - override_title: Option<&str>, - header_title: Option<&str>, - index: usize, - pad_width: usize, -) -> String { - override_title - .map(str::trim) - .filter(|t| !t.is_empty()) - .or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty())) - .map(|t| t.to_string()) - .unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width)) -} - -/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and -/// remap the remaining matches. Returns whether a replacement was made (false if -/// the indices are out of range or the match's bounds aren't valid boundaries). -fn splice_fix( - buffer: &mut String, - matches: &mut Vec, - match_idx: usize, - rep_idx: usize, -) -> bool { - let Some(m) = matches.get(match_idx).cloned() else { - return false; - }; - let Some(replacement) = m.replacements.get(rep_idx).cloned() else { - return false; - }; - if m.end > buffer.len() - || !buffer.is_char_boundary(m.start) - || !buffer.is_char_boundary(m.end) - { - return false; - } - buffer.replace_range(m.start..m.end, &replacement); - // The applied match overlaps its own region, so it is dropped here too. - remap_matches(matches, m.start, m.end, replacement.len()); - true -} - -/// Re-position matches after the byte range `[s, e)` was replaced with `new_len` -/// bytes. Matches that overlapped the edited region (including the one that was -/// just applied) are dropped; matches entirely after it are shifted by the -/// length delta so their highlights and offsets stay valid. -fn remap_matches(matches: &mut Vec, s: usize, e: usize, new_len: usize) { - matches.retain(|o| o.end <= s || o.start >= e); - let delta = new_len as i64 - (e - s) as i64; - for o in matches.iter_mut() { - if o.start >= e { - o.start = (o.start as i64 + delta) as usize; - o.end = (o.end as i64 + delta) as usize; - } - } -} - -/// Count words in a string: runs of non-whitespace separated by whitespace. -fn count_words(s: &str) -> usize { - s.split_whitespace().count() -} - -/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour -/// (amber under the range, green within it, blue over it), and a label like -/// "1,234 / 1,500–2,000". -fn goal_progress(goal: crate::preprocess::WordGoal, prose: usize) -> (f32, egui::Color32, String) { - let frac = if goal.max == 0 { - 0.0 - } else { - (prose as f32 / goal.max as f32).clamp(0.0, 1.0) - }; - let color = if prose < goal.min { - egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target - } else if prose <= goal.max { - egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range - } else { - egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target - }; - let target = if goal.min == goal.max { - thousands(goal.max) - } else { - format!("{}–{}", thousands(goal.min), thousands(goal.max)) - }; - (frac, color, format!("{} / {}", thousands(prose), target)) -} - -/// Paint a compact word-count-target progress bar for one file-list row, with -/// the numeric progress (and target) as a hover tooltip. The colour matches the -/// status-bar bar: amber under target, green in range, blue over. -fn row_goal_bar(ui: &mut egui::Ui, width: f32, goal: crate::preprocess::WordGoal, prose: usize) { - let (frac, color, text) = goal_progress(goal, prose); - let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover()); - let rounding = egui::Rounding::same(2.0); - let track = ui.visuals().extreme_bg_color; - let painter = ui.painter(); - painter.rect_filled(rect, rounding, track); - if frac > 0.0 { - let fill = egui::Rect::from_min_size( - rect.min, - egui::vec2((rect.width() * frac).max(1.0), rect.height()), - ); - painter.rect_filled(fill, rounding, color); - } - resp.on_hover_text(format!("{text} words")); -} - -/// Collect the `#+ Name:` field names from a file's header (the lines above the -/// draft marker). Returns empty when the marker is disabled or not present, so -/// prose headings in marker-less files aren't mistaken for fields. -fn header_field_names(text: &str, marker: &str) -> Vec { - let marker_l = marker.trim().to_ascii_lowercase(); - if marker_l.is_empty() { - return Vec::new(); - } - let has_marker = text - .lines() - .any(|l| l.trim_start().to_ascii_lowercase().starts_with(&marker_l)); - if !has_marker { - return Vec::new(); - } - let mut names = Vec::new(); - for line in text.lines() { - let t = line.trim_start(); - if t.to_ascii_lowercase().starts_with(&marker_l) { - break; // reached the marker; the rest is prose - } - if let Some(name) = field_name_of_line(t) { - names.push(name); - } - } - names -} - -/// If `trimmed` (a left-trimmed line) is a `#+ Name:` field line, return the -/// field name. Names are limited to short, plain strings to avoid catching -/// ordinary prose headings that merely contain a colon. -fn field_name_of_line(trimmed: &str) -> Option { - let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); - if hashes == 0 { - return None; - } - let after = &trimmed[hashes..]; - let ws = after.len() - after.trim_start().len(); - if ws == 0 { - return None; // need whitespace after the #'s - } - let rest = after.trim_start(); - let colon = rest.find(':')?; - let name = rest[..colon].trim(); - let plausible = !name.is_empty() - && name.chars().count() <= 40 - && name - .chars() - .all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '\''); - plausible.then(|| name.to_string()) -} - -/// The byte offset where the draft-marker line begins in `buffer`, if present. -fn marker_line_start(buffer: &str, marker: &str) -> Option { - let marker_l = marker.trim().to_ascii_lowercase(); - if marker_l.is_empty() { - return None; - } - let mut off = 0; - for line in buffer.split_inclusive('\n') { - if line.trim().to_ascii_lowercase().starts_with(&marker_l) { - return Some(off); - } - off += line.len(); - } - None -} - -/// If the caret sits in a header field-name position — on a `#+ ` line -/// above the draft marker, before any colon — return the byte range of the -/// partial field text and the partial itself, for autocompletion. -/// -/// Returns `(field_start, partial)`; the caret byte is the caller's `cursor`. -fn header_completion_context(buffer: &str, cursor: usize, marker: &str) -> Option<(usize, String)> { - if marker.trim().is_empty() || cursor > buffer.len() || !buffer.is_char_boundary(cursor) { - return None; - } - let line_start = buffer[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0); - // Suppress once the caret's line is at or past a present marker line. - if let Some(ms) = marker_line_start(buffer, marker) { - if line_start >= ms { - return None; - } - } - let before = &buffer[line_start..cursor]; - let trimmed = before.trim_start(); - let indent = before.len() - trimmed.len(); - let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); - if hashes == 0 { - return None; - } - let after_hash = &trimmed[hashes..]; - let ws = after_hash.len() - after_hash.trim_start().len(); - if ws == 0 { - return None; // need whitespace after the #'s - } - let partial = &after_hash[ws..]; - if partial.contains(':') { - return None; // past the field name, into the value - } - let field_start = line_start + indent + hashes + ws; - Some((field_start, partial.to_string())) -} - -/// Field names whose start matches `partial` (case-insensitive), best first. -/// An empty (or whitespace-only) partial matches nothing. -fn field_matches(fields: &[String], partial: &str) -> Vec { - let p = partial.trim().to_lowercase(); - if p.is_empty() { - return Vec::new(); - } - let mut out: Vec = fields - .iter() - .filter(|f| f.to_lowercase().starts_with(&p)) - .cloned() - .collect(); - // Shorter (closer) matches first, then alphabetical. - out.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); - out.dedup(); - out.truncate(8); - out -} - -/// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345"). -fn thousands(n: usize) -> String { - let digits = n.to_string(); - let len = digits.len(); - let mut out = String::with_capacity(len + len / 3); - for (i, ch) in digits.chars().enumerate() { - if i > 0 && (len - i) % 3 == 0 { - out.push(','); - } - out.push(ch); - } - out -} - -/// A dependency-free timestamp for commit messages (UTC seconds since epoch). -fn chrono_like_timestamp() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - format!("@{secs}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn counts_words_across_whitespace() { - assert_eq!(count_words(""), 0); - assert_eq!(count_words(" \n\t "), 0); - assert_eq!(count_words("one"), 1); - assert_eq!(count_words("one two three"), 3); - assert_eq!(count_words(" spread \n over\tlines "), 3); - } - - #[test] - fn chapter_title_falls_back_to_position() { - // No override, no header title -> 1-based index with a period (width 1). - assert_eq!(resolve_chapter_title(None, None, 0, 1), "1."); - assert_eq!(resolve_chapter_title(None, None, 2, 1), "3."); - // Header title is used when present. - assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate"); - // Override wins over everything, even a header title. - assert_eq!( - resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1), - "My Override" - ); - // Blank/whitespace override or header title are ignored. - assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2."); - assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7."); - } - - #[test] - fn chapter_number_zero_pads_to_width() { - // Width 2 pads single digits; wider numbers are unaffected. - assert_eq!(resolve_chapter_title(None, None, 0, 2), "01."); - assert_eq!(resolve_chapter_title(None, None, 8, 2), "09."); - assert_eq!(resolve_chapter_title(None, None, 11, 2), "12."); - assert_eq!(resolve_chapter_title(None, None, 4, 3), "005."); - // Padding never applies to a real title. - assert_eq!( - resolve_chapter_title(None, Some("The Gate"), 0, 3), - "The Gate" - ); - } - - fn m(start: usize, end: usize) -> crate::langtool::Match { - crate::langtool::Match { - start, - end, - message: String::new(), - replacements: Vec::new(), - spelling: false, - } - } - - #[test] - fn remap_shifts_later_and_drops_overlapping() { - // Text "aaaa BBB cccc dddd"; replace "BBB" (5..8, len 3) with "XX" (len 2). - let mut matches = vec![m(0, 4), m(5, 8), m(9, 13), m(14, 18)]; - remap_matches(&mut matches, 5, 8, 2); - // The applied match (5..8) is dropped; the one before is untouched; - // the two after shift left by 1. - assert_eq!(matches.len(), 3); - assert_eq!((matches[0].start, matches[0].end), (0, 4)); - assert_eq!((matches[1].start, matches[1].end), (8, 12)); - assert_eq!((matches[2].start, matches[2].end), (13, 17)); - } - - #[test] - fn remap_grows_when_replacement_is_longer() { - let mut matches = vec![m(0, 3), m(10, 14)]; - // Replace [0,3) (len 3) with 5 bytes: delta +2 shifts the later match. - remap_matches(&mut matches, 0, 3, 5); - assert_eq!(matches.len(), 1); - assert_eq!((matches[0].start, matches[0].end), (12, 16)); - } - - #[test] - fn remap_drops_matches_overlapping_the_edit() { - // A match straddling the edit boundary is discarded, not mis-shifted. - let mut matches = vec![m(2, 7)]; - remap_matches(&mut matches, 5, 8, 1); - assert!(matches.is_empty()); - } - - #[test] - fn lerp_color_hits_endpoints_and_midpoint() { - let a = egui::Color32::from_rgb(0, 0, 0); - let b = egui::Color32::from_rgb(200, 100, 50); - assert_eq!(lerp_color(a, b, 0.0), a); - assert_eq!(lerp_color(a, b, 1.0), b); - assert_eq!(lerp_color(a, b, 0.5), egui::Color32::from_rgb(100, 50, 25)); - // t is clamped to [0, 1]. - assert_eq!(lerp_color(a, b, -1.0), a); - assert_eq!(lerp_color(a, b, 2.0), b); - } - - #[test] - fn editor_contrast_moves_toward_white_in_dark_mode() { - let dark = egui::Visuals::dark(); - // 0% keeps the theme's text colour; 100% is fully white. - assert_eq!(editor_text_color(&dark, 0.0), dark.text_color()); - assert_eq!(editor_text_color(&dark, 100.0), egui::Color32::WHITE); - // In light mode, full contrast is black instead. - let light = egui::Visuals::light(); - assert_eq!(editor_text_color(&light, 100.0), egui::Color32::BLACK); - } - - #[test] - fn formats_thousands_separators() { - assert_eq!(thousands(0), "0"); - assert_eq!(thousands(42), "42"); - assert_eq!(thousands(999), "999"); - assert_eq!(thousands(1_000), "1,000"); - assert_eq!(thousands(12_345), "12,345"); - assert_eq!(thousands(1_234_567), "1,234,567"); - } - - #[test] - fn wraps_a_selection_in_bold_and_keeps_it_selected() { - let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold); - assert_eq!(text, "a **word** here"); - // The original "word" stays selected, shifted past the "**". - assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); - } - - #[test] - fn empty_selection_inserts_markers_with_caret_between() { - let (text, sel) = apply_format("ab", 1..1, Fmt::Italic); - assert_eq!(text, "a**b"); - // Caret lands between the two "*", i.e. char index 2. - assert_eq!(sel, 2..2); - } - - #[test] - fn re_applying_bold_inside_selection_unwraps_it() { - // Selecting "**word**" and hitting bold again removes the markers. - let (text, sel) = apply_format("a **word** here", 2..10, Fmt::Bold); - assert_eq!(text, "a word here"); - assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); - } - - #[test] - fn bold_unwraps_when_markers_surround_the_selection() { - // Selecting just "word" inside **word** and hitting bold strips them. - let (text, sel) = apply_format("a **word** here", 4..8, Fmt::Bold); - assert_eq!(text, "a word here"); - assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); - } - - #[test] - fn link_wraps_selection_and_selects_the_url_placeholder() { - let (text, sel) = apply_format("see docs now", 4..8, Fmt::Link); - assert_eq!(text, "see [docs](url) now"); - assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url"); - } - - #[test] - fn find_matches_are_non_overlapping_and_ordered() { - // "aaaa" with needle "aa" yields two non-overlapping matches, not three. - assert_eq!(find_matches("aaaa", "aa", true), vec![(0, 2), (2, 4)]); - assert_eq!( - find_matches("the cat sat on the mat", "at", true), - vec![(5, 7), (9, 11), (20, 22)] - ); - // An empty needle matches nothing. - assert!(find_matches("anything", "", true).is_empty()); - // No occurrence. - assert!(find_matches("hello", "zz", true).is_empty()); - } - - #[test] - fn find_matches_respects_case_sensitivity() { - // Case-sensitive skips the capitalised occurrence. - assert_eq!(find_matches("Cat cat CAT", "cat", true), vec![(4, 7)]); - // Case-insensitive finds all three, with offsets into the original text. - assert_eq!( - find_matches("Cat cat CAT", "cat", false), - vec![(0, 3), (4, 7), (8, 11)] - ); - } - - #[test] - fn find_matches_maps_multibyte_offsets_case_insensitively() { - // "café Café" — the accented text keeps its byte offsets when matching - // case-insensitively (é is two bytes). - let hits = find_matches("café Café", "café", false); - assert_eq!(hits.len(), 2); - let (s0, e0) = hits[0]; - let (s1, e1) = hits[1]; - assert_eq!(&"café Café"[s0..e0], "café"); - assert_eq!(&"café Café"[s1..e1], "Café"); - } - - #[test] - fn replace_all_replaces_every_match() { - assert_eq!( - replace_all("the cat sat", "at", "AT", true), - ("the cAT sAT".to_string(), 2) - ); - // Case-insensitive replacement across mixed case. - assert_eq!( - replace_all("Cat cat", "cat", "dog", false), - ("dog dog".to_string(), 2) - ); - // No matches leaves the text untouched. - assert_eq!( - replace_all("hello", "zz", "!", true), - ("hello".to_string(), 0) - ); - // Replacing with a longer/shorter string doesn't corrupt boundaries. - assert_eq!( - replace_all("a.b.c", ".", " - ", true), - ("a - b - c".to_string(), 2) - ); - } - - #[test] - fn format_respects_multibyte_char_offsets() { - // "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must - // still land on the right bytes. - let (text, sel) = apply_format("café word", 5..9, Fmt::Code); - assert_eq!(text, "café `word`"); - assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); - } - - const DM: &str = "### Rough Draft:"; - - #[test] - fn field_matches_prefix_case_insensitive() { - let fields: Vec = ["POV", "Point of View", "Setting", "Slug"] - .iter() - .map(|s| s.to_string()) - .collect(); - assert_eq!(field_matches(&fields, "po"), vec!["POV", "Point of View"]); - assert_eq!(field_matches(&fields, "S"), vec!["Slug", "Setting"]); // shorter first - assert!(field_matches(&fields, "").is_empty()); - assert!(field_matches(&fields, "zz").is_empty()); - } - - #[test] - fn completion_context_offers_partial_above_marker() { - let buf = "# Title: X\n## PO\n### Rough Draft:\n\nprose"; - // Caret at the end of "## PO" (byte 16). - let got = header_completion_context(buf, 16, DM); - assert_eq!(got, Some((14, "PO".to_string()))); - assert_eq!(&buf[14..16], "PO"); - } - - #[test] - fn completion_context_handles_multiword_fields() { - // A field name may contain spaces; the partial spans them. - let buf = "## Word Count Ta\n### Rough Draft:\n\nx"; - let got = header_completion_context(buf, 16, DM); - assert_eq!(got, Some((3, "Word Count Ta".to_string()))); - assert!(field_matches( - &["Word Count Target".to_string()], - "Word Count Ta" - ) - .contains(&"Word Count Target".to_string())); - } - - #[test] - fn completion_context_suppressed_after_colon_and_below_marker() { - // Past the colon (into the value) → no completion. - let after_colon = "## POV: Bi\n### Rough Draft:\n\nx"; - assert_eq!(header_completion_context(after_colon, 10, DM), None); - // Below the marker (in prose) → no completion, even for a heading. - let below = "### Rough Draft:\n\n## Scene\n"; - let caret = below.find("## Scene").unwrap() + "## Scene".len(); - assert_eq!(header_completion_context(below, caret, DM), None); - // No marker configured → disabled. - assert_eq!(header_completion_context("## PO", 5, ""), None); - } - - #[test] - fn header_field_names_collects_above_marker_only() { - let buf = "# Title: A\n## POV: Bixby\n## Setting: hall\n\ - ### Rough Draft:\n\n## Not a field: prose\n"; - let names = header_field_names(buf, DM); - assert!(names.contains(&"Title".to_string())); - assert!(names.contains(&"POV".to_string())); - assert!(names.contains(&"Setting".to_string())); - assert!(!names.iter().any(|n| n == "Not a field")); - // A file with no marker contributes no learned names. - assert!(header_field_names("## POV: X\n\nprose", DM).is_empty()); - } -} diff --git a/src/app/autocomplete.rs b/src/app/autocomplete.rs new file mode 100644 index 0000000..86af9eb --- /dev/null +++ b/src/app/autocomplete.rs @@ -0,0 +1,270 @@ +//! Header field-name autocomplete: the popup state machine plus the parsing +//! helpers that decide which field names to offer above the draft marker. + +use super::*; + +impl App { + // ---- Header-field autocomplete ----------------------------------------- + + /// Rebuild the field-name list: the built-ins plus any `#+ Name:` fields + /// found in the header of every workspace file. + pub(super) fn rebuild_field_names(&mut self) { + let mut names: Vec = + DEFAULT_FIELD_NAMES.iter().map(|s| s.to_string()).collect(); + for name in &self.files { + let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); + for f in header_field_names(&text, &self.config.draft_marker) { + if !names.iter().any(|n| n.eq_ignore_ascii_case(&f)) { + names.push(f); + } + } + } + names.sort_by_key(|s| s.to_lowercase()); + self.field_names = names; + } + + /// Fold any new header field names from the current buffer into the list, so + /// a field you just invented autocompletes without reopening the workspace. + pub(super) fn merge_field_names_from_buffer(&mut self) { + let found = header_field_names(&self.buffer, &self.config.draft_marker); + let mut added = false; + for f in found { + if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) { + self.field_names.push(f); + added = true; + } + } + if added { + self.field_names.sort_by_key(|s| s.to_lowercase()); + } + } + + /// Insert the chosen field name at the caret, replacing the partial the user + /// had typed and leaving the caret after the inserted `": "`. + pub(super) fn apply_field_completion(&mut self, ctx: &egui::Context, field: &str) { + let Some(ac) = self.autocomplete.take() else { + return; + }; + // The stored offsets are from the last layout; a nav-key or click didn't + // edit the buffer, but guard against anything unexpected. + if ac.cursor > self.buffer.len() + || ac.field_start > ac.cursor + || !self.buffer.is_char_boundary(ac.field_start) + || !self.buffer.is_char_boundary(ac.cursor) + { + return; + } + let replacement = format!("{field}: "); + self.buffer.replace_range(ac.field_start..ac.cursor, &replacement); + self.dirty = true; + self.find_needs_refresh = true; + self.spell_dirty = true; + self.spell_last_edit = Some(Instant::now()); + + // Put the caret just after the inserted "Field: " and refocus the editor. + let new_cursor_byte = ac.field_start + replacement.len(); + let char_idx = self.buffer[..new_cursor_byte].chars().count(); + let id = egui::Id::new(EDITOR_ID); + if let Some(mut state) = egui::widgets::text_edit::TextEditState::load(ctx, id) { + state.cursor.set_char_range(Some(egui::text::CCursorRange::one( + egui::text::CCursor::new(char_idx), + ))); + state.store(ctx, id); + } + ctx.memory_mut(|m| m.request_focus(id)); + ctx.request_repaint(); + } +} + +/// Collect the `#+ Name:` field names from a file's header (the lines above the +/// draft marker). Returns empty when the marker is disabled or not present, so +/// prose headings in marker-less files aren't mistaken for fields. +pub(super) fn header_field_names(text: &str, marker: &str) -> Vec { + let marker_l = marker.trim().to_ascii_lowercase(); + if marker_l.is_empty() { + return Vec::new(); + } + let has_marker = text + .lines() + .any(|l| l.trim_start().to_ascii_lowercase().starts_with(&marker_l)); + if !has_marker { + return Vec::new(); + } + let mut names = Vec::new(); + for line in text.lines() { + let t = line.trim_start(); + if t.to_ascii_lowercase().starts_with(&marker_l) { + break; // reached the marker; the rest is prose + } + if let Some(name) = field_name_of_line(t) { + names.push(name); + } + } + names +} + +/// If `trimmed` (a left-trimmed line) is a `#+ Name:` field line, return the +/// field name. Names are limited to short, plain strings to avoid catching +/// ordinary prose headings that merely contain a colon. +pub(super) fn field_name_of_line(trimmed: &str) -> Option { + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + if hashes == 0 { + return None; + } + let after = &trimmed[hashes..]; + let ws = after.len() - after.trim_start().len(); + if ws == 0 { + return None; // need whitespace after the #'s + } + let rest = after.trim_start(); + let colon = rest.find(':')?; + let name = rest[..colon].trim(); + let plausible = !name.is_empty() + && name.chars().count() <= 40 + && name + .chars() + .all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '\''); + plausible.then(|| name.to_string()) +} + +/// The byte offset where the draft-marker line begins in `buffer`, if present. +pub(super) fn marker_line_start(buffer: &str, marker: &str) -> Option { + let marker_l = marker.trim().to_ascii_lowercase(); + if marker_l.is_empty() { + return None; + } + let mut off = 0; + for line in buffer.split_inclusive('\n') { + if line.trim().to_ascii_lowercase().starts_with(&marker_l) { + return Some(off); + } + off += line.len(); + } + None +} + +/// If the caret sits in a header field-name position — on a `#+ ` line +/// above the draft marker, before any colon — return the byte range of the +/// partial field text and the partial itself, for autocompletion. +/// +/// Returns `(field_start, partial)`; the caret byte is the caller's `cursor`. +pub(super) fn header_completion_context( + buffer: &str, + cursor: usize, + marker: &str, +) -> Option<(usize, String)> { + if marker.trim().is_empty() || cursor > buffer.len() || !buffer.is_char_boundary(cursor) { + return None; + } + let line_start = buffer[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0); + // Suppress once the caret's line is at or past a present marker line. + if let Some(ms) = marker_line_start(buffer, marker) { + if line_start >= ms { + return None; + } + } + let before = &buffer[line_start..cursor]; + let trimmed = before.trim_start(); + let indent = before.len() - trimmed.len(); + let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); + if hashes == 0 { + return None; + } + let after_hash = &trimmed[hashes..]; + let ws = after_hash.len() - after_hash.trim_start().len(); + if ws == 0 { + return None; // need whitespace after the #'s + } + let partial = &after_hash[ws..]; + if partial.contains(':') { + return None; // past the field name, into the value + } + let field_start = line_start + indent + hashes + ws; + Some((field_start, partial.to_string())) +} + +/// Field names whose start matches `partial` (case-insensitive), best first. +/// An empty (or whitespace-only) partial matches nothing. +pub(super) fn field_matches(fields: &[String], partial: &str) -> Vec { + let p = partial.trim().to_lowercase(); + if p.is_empty() { + return Vec::new(); + } + let mut out: Vec = fields + .iter() + .filter(|f| f.to_lowercase().starts_with(&p)) + .cloned() + .collect(); + // Shorter (closer) matches first, then alphabetical. + out.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); + out.dedup(); + out.truncate(8); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const DM: &str = "### Rough Draft:"; + + #[test] + fn field_matches_prefix_case_insensitive() { + let fields: Vec = ["POV", "Point of View", "Setting", "Slug"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(field_matches(&fields, "po"), vec!["POV", "Point of View"]); + assert_eq!(field_matches(&fields, "S"), vec!["Slug", "Setting"]); // shorter first + assert!(field_matches(&fields, "").is_empty()); + assert!(field_matches(&fields, "zz").is_empty()); + } + + #[test] + fn completion_context_offers_partial_above_marker() { + let buf = "# Title: X\n## PO\n### Rough Draft:\n\nprose"; + // Caret at the end of "## PO" (byte 16). + let got = header_completion_context(buf, 16, DM); + assert_eq!(got, Some((14, "PO".to_string()))); + assert_eq!(&buf[14..16], "PO"); + } + + #[test] + fn completion_context_handles_multiword_fields() { + // A field name may contain spaces; the partial spans them. + let buf = "## Word Count Ta\n### Rough Draft:\n\nx"; + let got = header_completion_context(buf, 16, DM); + assert_eq!(got, Some((3, "Word Count Ta".to_string()))); + assert!(field_matches( + &["Word Count Target".to_string()], + "Word Count Ta" + ) + .contains(&"Word Count Target".to_string())); + } + + #[test] + fn completion_context_suppressed_after_colon_and_below_marker() { + // Past the colon (into the value) → no completion. + let after_colon = "## POV: Bi\n### Rough Draft:\n\nx"; + assert_eq!(header_completion_context(after_colon, 10, DM), None); + // Below the marker (in prose) → no completion, even for a heading. + let below = "### Rough Draft:\n\n## Scene\n"; + let caret = below.find("## Scene").unwrap() + "## Scene".len(); + assert_eq!(header_completion_context(below, caret, DM), None); + // No marker configured → disabled. + assert_eq!(header_completion_context("## PO", 5, ""), None); + } + + #[test] + fn header_field_names_collects_above_marker_only() { + let buf = "# Title: A\n## POV: Bixby\n## Setting: hall\n\ + ### Rough Draft:\n\n## Not a field: prose\n"; + let names = header_field_names(buf, DM); + assert!(names.contains(&"Title".to_string())); + assert!(names.contains(&"POV".to_string())); + assert!(names.contains(&"Setting".to_string())); + assert!(!names.iter().any(|n| n == "Not a field")); + // A file with no marker contributes no learned names. + assert!(header_field_names("## POV: X\n\nprose", DM).is_empty()); + } +} diff --git a/src/app/beats.rs b/src/app/beats.rs new file mode 100644 index 0000000..8f9eb63 --- /dev/null +++ b/src/app/beats.rs @@ -0,0 +1,259 @@ +//! Mistral-backed plot-beat generation: building the prompt from a proposal, +//! polling the request, the result window and the API settings dialog. + +use super::*; + +impl App { + /// Pick a novel-proposal markdown file and generate three-act plot beats + /// from it with the Mistral API, in the background. Opens the settings window + /// instead if no API key is configured yet. + pub(super) fn generate_plot_beats(&mut self, ctx: &egui::Context) { + if self.beats_rx.is_some() { + return; // a generation is already running + } + if self.config.mistral_api_key.trim().is_empty() { + self.show_mistral_settings = true; + self.beats_status = "Set your Mistral API key first (Settings ▸ Mistral).".to_string(); + self.beats_output = Some(String::new()); + return; + } + + let mut dialog = rfd::FileDialog::new() + .set_title("Choose a novel proposal (markdown)") + .add_filter("Markdown / text", &["md", "markdown", "txt"]); + if let Some(dir) = self.workspace().to_str() { + dialog = dialog.set_directory(dir); + } + let Some(path) = dialog.pick_file() else { + return; // cancelled + }; + + let proposal = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + self.beats_status = format!("Could not read {}: {e}", path.display()); + self.beats_output = Some(String::new()); + return; + } + }; + if proposal.trim().is_empty() { + self.beats_status = "That file is empty — nothing to work from.".to_string(); + self.beats_output = Some(String::new()); + return; + } + self.beats_source_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string); + + let api_key = self.config.mistral_api_key.clone(); + let model = self.config.mistral_effective_model(); + let base = self.config.mistral_effective_base_url(); + let (tx, rx) = std::sync::mpsc::channel(); + self.beats_rx = Some(rx); + self.beats_status = "Generating plot beats…".to_string(); + self.beats_output = Some(String::new()); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let result = crate::mistral::generate_beats(&api_key, &model, &base, &proposal); + let _ = tx.send(result); + ctx.request_repaint(); + }); + } + + /// Pick up a finished plot-beat generation and show it (or its error). + pub(super) fn poll_beats(&mut self) { + let received = self.beats_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some(result) = received { + self.beats_rx = None; + match result { + Ok(beats) => { + self.beats_status = "Done — review, then save or copy.".to_string(); + self.beats_output = Some(beats); + } + Err(e) => { + self.beats_status = format!("✖ {e}"); + // Keep the window open so the error stays visible. + self.beats_output.get_or_insert_with(String::new); + } + } + } + } + + /// Write the generated beats to a new ` — beats.md` file in the + /// workspace (dodging name collisions), add it to the list, and open it. + pub(super) fn save_beats_as_file(&mut self, beats: &str) { + let stem = self + .beats_source_stem + .clone() + .unwrap_or_else(|| "plot".to_string()); + let mut name = format!("{stem} — beats.md"); + let mut n = 2; + while self.path_for(&name).exists() { + name = format!("{stem} — beats-{n}.md"); + n += 1; + } + let path = self.path_for(&name); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim()); + match std::fs::write(&path, contents) { + Ok(_) => { + if !self.files.contains(&name) { + self.files.push(name.clone()); + self.persist_order(); + } + if let Some(idx) = self.files.iter().position(|f| f == &name) { + self.selected = None; // force the buffer to reload + self.select(idx); + } + self.status = format!("Saved {name}"); + self.beats_output = None; // close the window; the file is now open + self.beats_status.clear(); + } + Err(e) => self.beats_status = format!("Save failed: {e}"), + } + } + + /// Floating window presenting the generated plot beats, with save / copy. + pub(super) fn beats_window(&mut self, ctx: &egui::Context) { + let mut open = true; + let mut close = false; + let mut save = false; + let running = self.beats_rx.is_some(); + // Edit a local copy so the button row can borrow `self` mutably; persist + // any edits back into `beats_output` afterwards. + let mut text = self.beats_output.clone().unwrap_or_default(); + egui::Window::new("✨ Plot beats (Mistral)") + .open(&mut open) + .resizable(true) + .default_width(560.0) + .default_height(520.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if running { + ui.spinner(); + } + if !self.beats_status.is_empty() { + ui.label(egui::RichText::new(&self.beats_status).weak()); + } + }); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut text) + .desired_width(f32::INFINITY) + .desired_rows(20) + .font(egui::TextStyle::Monospace), + ); + }); + ui.separator(); + ui.horizontal(|ui| { + let have = !text.trim().is_empty(); + if ui + .add_enabled(have, egui::Button::new("💾 Save as new file")) + .clicked() + { + save = true; + } + if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() { + ui.output_mut(|o| o.copied_text = text.clone()); + self.beats_status = "Copied to clipboard.".to_string(); + } + if ui.button("Close").clicked() { + close = true; + } + }); + }); + + // Persist edits made in the text box (unless we're about to close/save). + if self.beats_output.is_some() { + self.beats_output = Some(text.clone()); + } + if save { + self.save_beats_as_file(&text); + } else if close || !open { + self.beats_output = None; + self.beats_status.clear(); + } + } + + /// Floating window for editing the Mistral API connection settings. + pub(super) fn mistral_settings_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_mistral_settings; + let mut close_clicked = false; + egui::Window::new("Mistral settings") + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + let mut save_now = false; + egui::Grid::new("mistral_settings_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("API key:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.mistral_api_key) + .password(true) + .hint_text("from console.mistral.ai") + .desired_width(260.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Model:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.mistral_model) + .hint_text(crate::mistral::DEFAULT_MODEL) + .desired_width(260.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Base URL:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.mistral_base_url) + .hint_text(crate::mistral::DEFAULT_BASE_URL) + .desired_width(260.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + }); + + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "Endpoint: {}/v1/chat/completions", + self.config.mistral_effective_base_url() + )) + .weak() + .monospace(), + ); + ui.label( + egui::RichText::new( + "The key is stored in this app's config file in plain text. \ + The proposal you choose is sent to Mistral to generate the beats.", + ) + .small() + .weak(), + ); + ui.separator(); + if ui.button("Close").clicked() { + close_clicked = true; + } + if save_now { + self.config.save(); + } + }); + + let now_open = open && !close_clicked; + if self.show_mistral_settings && !now_open { + self.config.save(); + } + self.show_mistral_settings = now_open; + } +} diff --git a/src/app/editor.rs b/src/app/editor.rs new file mode 100644 index 0000000..ca3d413 --- /dev/null +++ b/src/app/editor.rs @@ -0,0 +1,520 @@ +//! The manuscript text editor: the widget itself, syntax/issue highlighting +//! for its layouter, and the markdown formatting hotkeys. + +use super::*; + +impl App { + pub(super) fn editor(&mut self, ui: &mut egui::Ui) { + // Base monospace size scaled by the zoom percentage. `desired_width` stays + // infinite and the scroll area is vertical-only, so text wraps to the pane + // width at any zoom (no horizontal scrolling). + let base = ui + .style() + .text_styles + .get(&egui::TextStyle::Monospace) + .map(|f| f.size) + .unwrap_or(12.0); + let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0); + + // Editor text colour, brightened toward max contrast by the contrast slider. + let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast); + + // Underline whichever check currently owns the buffer's issues + // (LanguageTool when fresh, otherwise the live spell checker). + let ranges: Vec<(usize, usize, bool)> = self + .current_matches() + .iter() + .map(|m| (m.start, m.end, m.spelling)) + .collect(); + + // Search-match highlights (all matches shaded, the active one stronger). + let find_ranges: Vec<(usize, usize)> = if self.show_find { + self.find_matches.clone() + } else { + Vec::new() + }; + let find_active = if self.show_find { + Some(self.find_active) + } else { + None + }; + + egui::ScrollArea::vertical() + .id_salt("editor") + .auto_shrink([false, false]) + .show(ui, |ui| { + let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { + let job = build_editor_job( + text, + size, + text_color, + &ranges, + &find_ranges, + find_active, + wrap_width, + ); + ui.fonts(|f| f.layout_job(job)) + }; + // Autocomplete: intercept navigation keys before the editor + // consumes them, acting on last frame's popup state. + let mut accept_field: Option = None; + let mut dismiss = false; + if let Some(ac) = self.autocomplete.as_mut() { + ui.input_mut(|i| { + if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) { + ac.selected = (ac.selected + 1) % ac.matches.len(); + } else if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) { + ac.selected = (ac.selected + ac.matches.len() - 1) % ac.matches.len(); + } else if i.consume_key(egui::Modifiers::NONE, egui::Key::Tab) + || i.consume_key(egui::Modifiers::NONE, egui::Key::Enter) + { + accept_field = ac.matches.get(ac.selected).cloned(); + } else if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { + dismiss = true; + } + }); + } + if dismiss { + // Remember the dismissed partial so it doesn't immediately reopen. + self.ac_dismissed = self.autocomplete.take().map(|a| a.partial); + } + if let Some(field) = accept_field { + self.apply_field_completion(ui.ctx(), &field); + } + + let output = egui::TextEdit::multiline(&mut self.buffer) + .id(egui::Id::new(EDITOR_ID)) + .code_editor() + .desired_width(f32::INFINITY) + .desired_rows(30) + .layouter(&mut layouter) + .show(ui); + if output.response.changed() { + self.dirty = true; + // The buffer changed, so search matches and the live spell + // check both need refreshing (the latter debounced). + self.find_needs_refresh = true; + self.spell_dirty = true; + self.spell_last_edit = Some(Instant::now()); + } + // Markdown formatting hotkeys, applied to the current selection + // while the editor is focused (Ctrl/Cmd + B / I / E / K, and + // Ctrl/Cmd+Shift+X for strikethrough). + if output.response.has_focus() { + if let Some(fmt) = ui.input_mut(detect_format_hotkey) { + let sel = output + .cursor_range + .map(|cr| cr.as_sorted_char_range()) + .unwrap_or(0..0); + let (new_text, new_sel) = apply_format(&self.buffer, sel, fmt); + self.buffer = new_text; + self.dirty = true; + // Restore the caret/selection around the change. + let mut state = output.state; + state.cursor.set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(new_sel.start), + egui::text::CCursor::new(new_sel.end), + ))); + state.store(ui.ctx(), output.response.id); + ui.ctx().request_repaint(); + } + } + + // Scroll the active search match into view when requested (on + // open, Next/Prev, Enter, or after a replace). + if self.find_scroll { + if let Some(&(s, _)) = self.find_matches.get(self.find_active) { + let char_idx = self + .buffer + .get(..s) + .map(|p| p.chars().count()) + .unwrap_or(0); + let local = + output.galley.pos_from_ccursor(egui::text::CCursor::new(char_idx)); + let onscreen = local.translate(output.galley_pos.to_vec2()); + ui.scroll_to_rect(onscreen, Some(egui::Align::Center)); + } + self.find_scroll = false; + } + + // Right-click a misspelling → a menu of suggested corrections. + if output.response.secondary_clicked() { + self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| { + let local = pos - output.galley_pos; + let cursor = output.galley.cursor_from_pos(local); + let byte = char_to_byte(&self.buffer, cursor.ccursor.index); + self.match_index_at(byte) + }); + } + // Snapshot the target's suggestions (owned) so the menu closure + // doesn't borrow self; compute them on demand if the background + // check hadn't produced any for this word. + let menu: Option<(usize, Vec)> = self.spell_menu.and_then(|i| { + let m = self.current_matches().get(i)?; + let mut reps = m.replacements.clone(); + if reps.is_empty() { + if let (Some(dict), Some(word)) = + (&self.spell_dict, self.buffer.get(m.start..m.end)) + { + reps = crate::spell::suggestions(dict, word); + } + } + Some((i, reps)) + }); + let mut chosen: Option<(usize, usize)> = None; + output.response.context_menu(|ui| { + match &menu { + Some((i, reps)) if !reps.is_empty() => { + ui.label(egui::RichText::new("Suggestions").strong()); + for (j, rep) in reps.iter().enumerate() { + if ui.button(rep).clicked() { + chosen = Some((*i, j)); + ui.close_menu(); + } + } + } + Some(_) => { + ui.label(egui::RichText::new("No suggestions").weak()); + } + None => { + ui.label(egui::RichText::new("No spelling issue here").weak()); + } + } + }); + if let Some((i, j)) = chosen { + self.apply_current_fix(i, j); + self.spell_menu = None; + } + + // Recompute the header-field autocomplete from the caret so the + // popup tracks what the user is typing (only for a collapsed + // caret in the focused editor). + if output.response.has_focus() { + let caret = output.cursor_range.and_then(|cr| { + let r = cr.as_sorted_char_range(); + (r.start == r.end).then_some(r.end) + }); + let mut next = None; + let mut still_dismissed = false; + if let Some(caret_char) = caret { + let caret_byte = char_to_byte(&self.buffer, caret_char); + if let Some((field_start, partial)) = header_completion_context( + &self.buffer, + caret_byte, + &self.config.draft_marker, + ) { + let partial_l = partial.to_lowercase(); + if self.ac_dismissed.as_deref() == Some(partial_l.as_str()) { + // Esc-dismissed for exactly this partial; stay closed. + still_dismissed = true; + } else { + let matches = field_matches(&self.field_names, &partial); + if !matches.is_empty() { + // Keep the highlight while typing the same prefix. + let selected = match &self.autocomplete { + Some(prev) + if prev.partial == partial_l + && prev.selected < matches.len() => + { + prev.selected + } + _ => 0, + }; + let caret_rect = output + .galley + .pos_from_ccursor(egui::text::CCursor::new(caret_char)); + let anchor = caret_rect.left_bottom() + + output.galley_pos.to_vec2() + + egui::vec2(0.0, 2.0); + next = Some(Autocomplete { + matches, + selected, + partial: partial_l, + field_start, + cursor: caret_byte, + anchor, + }); + } + } + } + } + self.autocomplete = next; + if !still_dismissed { + self.ac_dismissed = None; + } + } else { + self.autocomplete = None; + } + }); + + // Header-field autocomplete popup, floating just below the caret. + let mut clicked_field: Option = None; + if let Some(ac) = &self.autocomplete { + egui::Area::new(egui::Id::new("field_autocomplete")) + .order(egui::Order::Foreground) + .fixed_pos(ac.anchor) + .constrain(true) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_max_width(260.0); + for (i, m) in ac.matches.iter().enumerate() { + if ui.selectable_label(i == ac.selected, m).clicked() { + clicked_field = Some(m.clone()); + } + } + }); + }); + } + if let Some(field) = clicked_field { + self.apply_field_completion(ui.ctx(), &field); + } + } +} + +/// Build the editor's laid-out text, underlining grammar/spelling matches and +/// shading search matches. +/// +/// `grammar` are `(start_byte, end_byte, is_spelling)` triples: spelling issues +/// get a red underline, grammar/style issues a blue one. `find` are +/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent +/// highlight; the one at index `find_active` (if any) is shaded more strongly. +pub(super) fn build_editor_job( + text: &str, + size: f32, + color: egui::Color32, + grammar: &[(usize, usize, bool)], + find: &[(usize, usize)], + find_active: Option, + wrap_width: f32, +) -> egui::text::LayoutJob { + use egui::text::{LayoutJob, TextFormat}; + let font_id = egui::FontId::monospace(size); + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + + if grammar.is_empty() && find.is_empty() { + job.append(text, 0.0, TextFormat::simple(font_id, color)); + return job; + } + + // Split the text at every match boundary (grammar and find), then format + // each run according to which ranges cover it. + let mut points: Vec = vec![0, text.len()]; + for &(s, e, _) in grammar { + points.push(s); + points.push(e); + } + for &(s, e) in find { + points.push(s); + points.push(e); + } + points.retain(|&p| p <= text.len() && text.is_char_boundary(p)); + points.sort_unstable(); + points.dedup(); + + let spell_color = egui::Color32::from_rgb(0xE0, 0x40, 0x40); + let grammar_color = egui::Color32::from_rgb(0x3B, 0x82, 0xF6); + // Translucent so the highlight reads in both light and dark themes. + let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70); + let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150); + let width = (size * 0.08).max(1.5); + + for w in points.windows(2) { + let (a, b) = (w[0], w[1]); + if a >= b { + continue; + } + let mut fmt = TextFormat::simple(font_id.clone(), color); + if let Some(&(_, _, spelling)) = + grammar.iter().find(|&&(s, e, _)| s < e && a >= s && b <= e) + { + let c = if spelling { spell_color } else { grammar_color }; + fmt.underline = egui::Stroke::new(width, c); + } + if let Some(i) = find + .iter() + .position(|&(s, e)| s < e && a >= s && b <= e) + { + fmt.background = if Some(i) == find_active { + find_active_bg + } else { + find_bg + }; + } + job.append(&text[a..b], 0.0, fmt); + } + job +} + +/// An inline markdown formatting action triggered by an editor hotkey. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Fmt { + Bold, + Italic, + Code, + Strike, + Link, +} + +impl Fmt { + /// The `(prefix, suffix)` markers wrapped around the selection. + fn markers(self) -> (&'static str, &'static str) { + match self { + Fmt::Bold => ("**", "**"), + Fmt::Italic => ("*", "*"), + Fmt::Code => ("`", "`"), + Fmt::Strike => ("~~", "~~"), + Fmt::Link => ("[", "](url)"), + } + } +} + +/// Map a formatting hotkey (if pressed this frame) to its action, consuming the +/// key event so nothing else reacts to it. Cmd+Shift+X is checked first because +/// `consume_key` ignores an *extra* Shift, so a bare Cmd+X test would swallow it. +pub(super) fn detect_format_hotkey(input: &mut egui::InputState) -> Option { + use egui::{Key, Modifiers}; + let cmd = Modifiers::COMMAND; + if input.consume_key(cmd | Modifiers::SHIFT, Key::X) { + return Some(Fmt::Strike); + } + if input.consume_key(cmd, Key::B) { + return Some(Fmt::Bold); + } + if input.consume_key(cmd, Key::I) { + return Some(Fmt::Italic); + } + if input.consume_key(cmd, Key::E) { + return Some(Fmt::Code); + } + if input.consume_key(cmd, Key::K) { + return Some(Fmt::Link); + } + None +} + +/// Byte offset of the `char_idx`-th character in `s` (or `s.len()` if past the end). +pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices() + .nth(char_idx) + .map(|(b, _)| b) + .unwrap_or(s.len()) +} + +/// Apply an inline-format action to `text` given a selection in *character* +/// indices, returning the new text and the new selection (also character indices). +/// +/// Symmetric markers toggle: if the selection is already wrapped — markers just +/// inside it, or immediately surrounding it — the markers are removed; otherwise +/// they are added (an empty selection just drops the markers with the caret +/// between them). A link wraps the selection as the link text and selects the +/// `url` placeholder so it can be replaced. +pub(super) fn apply_format( + text: &str, + sel: std::ops::Range, + fmt: Fmt, +) -> (String, std::ops::Range) { + let (prefix, suffix) = fmt.markers(); + let start = sel.start.min(sel.end); + let end = sel.start.max(sel.end); + let b_start = char_to_byte(text, start); + let b_end = char_to_byte(text, end); + let selected = &text[b_start..b_end]; + let plen = prefix.chars().count(); + let slen = suffix.chars().count(); + + if fmt == Fmt::Link { + let new = format!( + "{}{}{}{}{}", + &text[..b_start], prefix, selected, suffix, &text[b_end..] + ); + // The url placeholder sits after "](" (2 chars) inside the suffix. + let url_start = start + plen + selected.chars().count() + 2; + return (new, url_start..url_start + 3); + } + + // Toggle: markers immediately inside the selection. + if selected.starts_with(prefix) + && selected.ends_with(suffix) + && selected.len() >= prefix.len() + suffix.len() + { + let inner = &selected[prefix.len()..selected.len() - suffix.len()]; + let new = format!("{}{}{}", &text[..b_start], inner, &text[b_end..]); + return (new, start..(end - plen - slen)); + } + + // Toggle: markers immediately surrounding the selection. + if text[..b_start].ends_with(prefix) && text[b_end..].starts_with(suffix) { + let new = format!( + "{}{}{}", + &text[..b_start - prefix.len()], + selected, + &text[b_end + suffix.len()..] + ); + return (new, (start - plen)..(end - plen)); + } + + // Wrap. + let new = format!( + "{}{}{}{}{}", + &text[..b_start], prefix, selected, suffix, &text[b_end..] + ); + if start == end { + (new, (start + plen)..(start + plen)) + } else { + (new, (start + plen)..(end + plen)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wraps_a_selection_in_bold_and_keeps_it_selected() { + let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold); + assert_eq!(text, "a **word** here"); + // The original "word" stays selected, shifted past the "**". + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } + + #[test] + fn empty_selection_inserts_markers_with_caret_between() { + let (text, sel) = apply_format("ab", 1..1, Fmt::Italic); + assert_eq!(text, "a**b"); + // Caret lands between the two "*", i.e. char index 2. + assert_eq!(sel, 2..2); + } + + #[test] + fn re_applying_bold_inside_selection_unwraps_it() { + // Selecting "**word**" and hitting bold again removes the markers. + let (text, sel) = apply_format("a **word** here", 2..10, Fmt::Bold); + assert_eq!(text, "a word here"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } + + #[test] + fn bold_unwraps_when_markers_surround_the_selection() { + // Selecting just "word" inside **word** and hitting bold strips them. + let (text, sel) = apply_format("a **word** here", 4..8, Fmt::Bold); + assert_eq!(text, "a word here"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } + + #[test] + fn link_wraps_selection_and_selects_the_url_placeholder() { + let (text, sel) = apply_format("see docs now", 4..8, Fmt::Link); + assert_eq!(text, "see [docs](url) now"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url"); + } + + #[test] + fn format_respects_multibyte_char_offsets() { + // "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must + // still land on the right bytes. + let (text, sel) = apply_format("café word", 5..9, Fmt::Code); + assert_eq!(text, "café `word`"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } +} diff --git a/src/app/file_list.rs b/src/app/file_list.rs new file mode 100644 index 0000000..9ab8d5f --- /dev/null +++ b/src/app/file_list.rs @@ -0,0 +1,256 @@ +//! The left-hand file list: drag-to-reorder rows, chapter titles and the +//! per-file word-count progress bar. + +use super::*; + +impl App { + pub(super) fn left_pane(&mut self, ctx: &egui::Context) { + egui::SidePanel::left("files") + .resizable(true) + .default_width(260.0) + .show(ctx, |ui| { + // The default theme renders unselected list rows fairly dim; bump + // the widget text colours so file names stay legible (especially in + // dark mode) without affecting the rest of the app. + boost_list_contrast(ui.visuals_mut()); + ui.add_space(4.0); + ui.heading("Files"); + ui.label( + egui::RichText::new("drag ⠿ to reorder") + .small() + .weak(), + ); + ui.separator(); + + let mut clicked: Option = None; + let mut from_to: Option<(usize, usize)> = None; + let pointer = ui.input(|i| i.pointer.interact_pos()); + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(ui.available_height() - 120.0) + .show(ui, |ui| { + for idx in 0..self.files.len() { + let name = self.files[idx].clone(); + let selected = self.selected == Some(idx); + // The selected file's fields are read live from the + // buffer (so unsaved edits show); others come from the + // cache filled on open/save. + let meta = if selected { + FileMeta::from_markdown(&self.buffer, &self.config.draft_marker) + } else { + self.file_meta.get(&name).cloned().unwrap_or_default() + }; + let tooltip = meta.tooltip(); + let row = ui + .horizontal(|ui| { + ui.dnd_drag_source( + egui::Id::new(("dnd", &name)), + idx, + |ui| { + ui.label( + egui::RichText::new("⠿").monospace().weak(), + ); + }, + ); + // Reserve room on the right for a per-file + // word-count-target bar when the file sets one. + let bar_w = 44.0; + let reserve = if meta.goal.is_some() { bar_w + 6.0 } else { 0.0 }; + let label_w = (ui.available_width() - reserve).max(24.0); + let mut label = ui.add_sized( + [label_w, 20.0], + egui::SelectableLabel::new(selected, &name), + ); + if let Some(tooltip) = &tooltip { + label = label.on_hover_text(tooltip); + } + if label.clicked() { + clicked = Some(idx); + } + if let Some(goal) = meta.goal { + row_goal_bar(ui, bar_w, goal, meta.prose_words); + } + }) + .response; + + // Drop handling: is a dragged item hovering this row? + if let Some(_payload) = row.dnd_hover_payload::() { + let rect = row.rect; + let before = pointer + .map(|p| p.y < rect.center().y) + .unwrap_or(true); + let y = if before { rect.top() } else { rect.bottom() }; + ui.painter().hline( + rect.x_range(), + y, + egui::Stroke::new( + 2.0, + ui.visuals().selection.stroke.color, + ), + ); + if let Some(payload) = row.dnd_release_payload::() { + let target = if before { idx } else { idx + 1 }; + from_to = Some((*payload, target)); + } + } + } + }); + + if let Some(idx) = clicked { + self.select(idx); + } + if let Some((from, to)) = from_to { + self.reorder(from, to); + } + + ui.separator(); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.new_name) + .hint_text("new file name") + .desired_width(150.0), + ); + if ui.button("+ New").clicked() { + self.create_file(); + } + }); + + if self.selected.is_some() { + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.rename_input) + .hint_text("rename") + .desired_width(150.0), + ); + if ui.button("Rename").clicked() { + self.rename_selected(); + } + }); + ui.horizontal(|ui| { + if !self.pending_delete { + if ui.button("🗑 Delete").clicked() { + self.pending_delete = true; + } + } else { + ui.label("Delete file?"); + if ui + .button(egui::RichText::new("Yes").color(egui::Color32::RED)) + .clicked() + { + self.delete_selected(); + } + if ui.button("No").clicked() { + self.pending_delete = false; + } + } + }); + } + }); + } +} + +/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the +/// `# Title:` header value, otherwise the chapter's 1-based position followed by +/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding). +pub(super) fn resolve_chapter_title( + override_title: Option<&str>, + header_title: Option<&str>, + index: usize, + pad_width: usize, +) -> String { + override_title + .map(str::trim) + .filter(|t| !t.is_empty()) + .or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty())) + .map(|t| t.to_string()) + .unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width)) +} + +/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour +/// (amber under the range, green within it, blue over it), and a label like +/// "1,234 / 1,500–2,000". +pub(super) fn goal_progress( + goal: crate::preprocess::WordGoal, + prose: usize, +) -> (f32, egui::Color32, String) { + let frac = if goal.max == 0 { + 0.0 + } else { + (prose as f32 / goal.max as f32).clamp(0.0, 1.0) + }; + let color = if prose < goal.min { + egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target + } else if prose <= goal.max { + egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range + } else { + egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target + }; + let target = if goal.min == goal.max { + thousands(goal.max) + } else { + format!("{}–{}", thousands(goal.min), thousands(goal.max)) + }; + (frac, color, format!("{} / {}", thousands(prose), target)) +} + +/// Paint a compact word-count-target progress bar for one file-list row, with +/// the numeric progress (and target) as a hover tooltip. The colour matches the +/// status-bar bar: amber under target, green in range, blue over. +pub(super) fn row_goal_bar( + ui: &mut egui::Ui, + width: f32, + goal: crate::preprocess::WordGoal, + prose: usize, +) { + let (frac, color, text) = goal_progress(goal, prose); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover()); + let rounding = egui::Rounding::same(2.0); + let track = ui.visuals().extreme_bg_color; + let painter = ui.painter(); + painter.rect_filled(rect, rounding, track); + if frac > 0.0 { + let fill = egui::Rect::from_min_size( + rect.min, + egui::vec2((rect.width() * frac).max(1.0), rect.height()), + ); + painter.rect_filled(fill, rounding, color); + } + resp.on_hover_text(format!("{text} words")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chapter_title_falls_back_to_position() { + // No override, no header title -> 1-based index with a period (width 1). + assert_eq!(resolve_chapter_title(None, None, 0, 1), "1."); + assert_eq!(resolve_chapter_title(None, None, 2, 1), "3."); + // Header title is used when present. + assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate"); + // Override wins over everything, even a header title. + assert_eq!( + resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1), + "My Override" + ); + // Blank/whitespace override or header title are ignored. + assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2."); + assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7."); + } + + #[test] + fn chapter_number_zero_pads_to_width() { + // Width 2 pads single digits; wider numbers are unaffected. + assert_eq!(resolve_chapter_title(None, None, 0, 2), "01."); + assert_eq!(resolve_chapter_title(None, None, 8, 2), "09."); + assert_eq!(resolve_chapter_title(None, None, 11, 2), "12."); + assert_eq!(resolve_chapter_title(None, None, 4, 3), "005."); + // Padding never applies to a real title. + assert_eq!( + resolve_chapter_title(None, Some("The Gate"), 0, 3), + "The Gate" + ); + } +} diff --git a/src/app/find.rs b/src/app/find.rs new file mode 100644 index 0000000..ae96e4d --- /dev/null +++ b/src/app/find.rs @@ -0,0 +1,340 @@ +//! The editor's find/replace bar and the string-search helpers behind it. + +use super::*; + +impl App { + // ---- Find / replace ---------------------------------------------------- + + /// Open the find/replace bar, focusing the search field (`focus_search`) or + /// the replace field, and queue a match refresh and a scroll to the first hit. + pub(super) fn open_find(&mut self, focus_search: bool) { + self.show_find = true; + self.find_focus = Some(focus_search); + self.find_needs_refresh = true; + self.find_scroll = true; + } + + /// Recompute the search matches for the current buffer and query, clamping + /// the active index and refreshing the status line. + pub(super) fn refresh_find(&mut self) { + self.find_matches = find_matches(&self.buffer, &self.find_query, self.find_case_sensitive); + if self.find_active >= self.find_matches.len() { + self.find_active = 0; + } + self.update_find_status(); + self.find_needs_refresh = false; + } + + pub(super) fn update_find_status(&mut self) { + self.find_status = if self.find_query.is_empty() { + String::new() + } else if self.find_matches.is_empty() { + "No matches".to_string() + } else { + format!("{} of {}", self.find_active + 1, self.find_matches.len()) + }; + } + + /// Move the active match forward (or backward), wrapping around, and ask the + /// editor to scroll it into view. + pub(super) fn find_step(&mut self, forward: bool) { + let n = self.find_matches.len(); + if n == 0 { + return; + } + self.find_active = if forward { + (self.find_active + 1) % n + } else { + (self.find_active + n - 1) % n + }; + self.find_scroll = true; + self.update_find_status(); + } + + /// Replace the active match with the replacement text, then re-search so the + /// remaining highlights stay accurate. + pub(super) fn replace_current(&mut self) { + let Some(&(s, e)) = self.find_matches.get(self.find_active) else { + return; + }; + if e > self.buffer.len() + || !self.buffer.is_char_boundary(s) + || !self.buffer.is_char_boundary(e) + { + return; + } + self.buffer.replace_range(s..e, &self.replace_query); + self.dirty = true; + // Editing invalidates any grammar-check offsets. + self.clear_lt(); + // Keep the same index so the "current" match becomes the next occurrence + // in document order; refresh recomputes and clamps it. + self.refresh_find(); + self.find_scroll = true; + self.status = "Replaced 1 occurrence".to_string(); + } + + /// Replace every match in the buffer in one pass. + pub(super) fn replace_all_matches(&mut self) { + if self.find_query.is_empty() { + return; + } + let (new, count) = replace_all( + &self.buffer, + &self.find_query, + &self.replace_query, + self.find_case_sensitive, + ); + if count > 0 { + self.buffer = new; + self.dirty = true; + self.clear_lt(); + self.find_active = 0; + self.refresh_find(); + } + self.status = format!("Replaced {count} occurrence(s)"); + } + + /// The find/replace bar shown above the editor when `show_find` is set. + pub(super) fn find_bar(&mut self, ui: &mut egui::Ui) { + egui::Frame::group(ui.style()).show(ui, |ui| { + // Find row. + ui.horizontal(|ui| { + ui.label("Find: "); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.find_query) + .desired_width(220.0) + .hint_text("search text"), + ); + if self.find_focus == Some(true) { + resp.request_focus(); + self.find_focus = None; + } + if resp.changed() { + self.find_active = 0; + self.refresh_find(); + self.find_scroll = true; + } + // Enter jumps to the next match, Shift+Enter to the previous one; + // keep the field focused so the key can be repeated. + if resp.lost_focus() { + let (enter, shift) = + ui.input(|i| (i.key_pressed(egui::Key::Enter), i.modifiers.shift)); + if enter { + self.find_step(!shift); + self.find_focus = Some(true); + } + } + let have = !self.find_matches.is_empty(); + if ui + .add_enabled(have, egui::Button::new("▲")) + .on_hover_text("Previous match (Shift+Enter)") + .clicked() + { + self.find_step(false); + } + if ui + .add_enabled(have, egui::Button::new("▼")) + .on_hover_text("Next match (Enter)") + .clicked() + { + self.find_step(true); + } + if ui + .checkbox(&mut self.find_case_sensitive, "Aa") + .on_hover_text("Match case") + .changed() + { + self.find_active = 0; + self.refresh_find(); + } + if !self.find_status.is_empty() { + ui.label(egui::RichText::new(&self.find_status).weak()); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("✕").on_hover_text("Close (Esc)").clicked() { + self.show_find = false; + } + }); + }); + // Replace row. + ui.horizontal(|ui| { + ui.label("Replace:"); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.replace_query) + .desired_width(220.0) + .hint_text("replacement text"), + ); + if self.find_focus == Some(false) { + resp.request_focus(); + self.find_focus = None; + } + let have = !self.find_matches.is_empty(); + if ui + .add_enabled(have, egui::Button::new("Replace")) + .on_hover_text("Replace the current match, then move to the next") + .clicked() + { + self.replace_current(); + } + if ui + .add_enabled(have, egui::Button::new("Replace all")) + .on_hover_text("Replace every match in this file") + .clicked() + { + self.replace_all_matches(); + } + }); + }); + ui.add_space(4.0); + } +} + +/// Find every non-overlapping occurrence of `needle` in `haystack`, returning +/// their `(start_byte, end_byte)` ranges in document order. An empty needle +/// matches nothing. When `case_sensitive` is false, matching is done on the +/// Unicode-lowercased text while the returned offsets index the original. +pub(super) fn find_matches( + haystack: &str, + needle: &str, + case_sensitive: bool, +) -> Vec<(usize, usize)> { + if needle.is_empty() { + return Vec::new(); + } + if case_sensitive { + let mut out = Vec::new(); + let mut from = 0; + while let Some(pos) = haystack[from..].find(needle) { + let s = from + pos; + let e = s + needle.len(); + out.push((s, e)); + // Advance past this match (at least one byte to guarantee progress). + from = e.max(s + 1); + } + return out; + } + + // Case-insensitive: search the lowercased haystack, mapping match offsets + // back to the original string's byte positions. + let (lower, map) = lowercased_with_map(haystack); + let low_needle = needle.to_lowercase(); + let mut out = Vec::new(); + let mut from = 0; + while let Some(pos) = lower[from..].find(&low_needle) { + let ls = from + pos; + let le = ls + low_needle.len(); + out.push((map[ls], map[le])); + from = le.max(ls + 1); + } + out +} + +/// Lowercase `s`, returning the lowercased string plus a map from each byte +/// index in it (including the terminal `len`) back to the byte index in `s` +/// where the originating character began. Used to translate case-insensitive +/// match offsets back onto the original text. +pub(super) fn lowercased_with_map(s: &str) -> (String, Vec) { + let mut lower = String::with_capacity(s.len()); + let mut map = Vec::with_capacity(s.len() + 1); + for (byte, ch) in s.char_indices() { + for lc in ch.to_lowercase() { + let mut buf = [0u8; 4]; + let encoded = lc.encode_utf8(&mut buf); + for _ in 0..encoded.len() { + map.push(byte); + } + lower.push_str(encoded); + } + } + map.push(s.len()); + (lower, map) +} + +/// Replace every non-overlapping match of `needle` in `text` with `replacement`, +/// returning the new text and the number of replacements made. +pub(super) fn replace_all( + text: &str, + needle: &str, + replacement: &str, + case_sensitive: bool, +) -> (String, usize) { + let matches = find_matches(text, needle, case_sensitive); + if matches.is_empty() { + return (text.to_string(), 0); + } + let mut out = String::with_capacity(text.len()); + let mut last = 0; + for &(s, e) in &matches { + out.push_str(&text[last..s]); + out.push_str(replacement); + last = e; + } + out.push_str(&text[last..]); + (out, matches.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_matches_are_non_overlapping_and_ordered() { + // "aaaa" with needle "aa" yields two non-overlapping matches, not three. + assert_eq!(find_matches("aaaa", "aa", true), vec![(0, 2), (2, 4)]); + assert_eq!( + find_matches("the cat sat on the mat", "at", true), + vec![(5, 7), (9, 11), (20, 22)] + ); + // An empty needle matches nothing. + assert!(find_matches("anything", "", true).is_empty()); + // No occurrence. + assert!(find_matches("hello", "zz", true).is_empty()); + } + + #[test] + fn find_matches_respects_case_sensitivity() { + // Case-sensitive skips the capitalised occurrence. + assert_eq!(find_matches("Cat cat CAT", "cat", true), vec![(4, 7)]); + // Case-insensitive finds all three, with offsets into the original text. + assert_eq!( + find_matches("Cat cat CAT", "cat", false), + vec![(0, 3), (4, 7), (8, 11)] + ); + } + + #[test] + fn find_matches_maps_multibyte_offsets_case_insensitively() { + // "café Café" — the accented text keeps its byte offsets when matching + // case-insensitively (é is two bytes). + let hits = find_matches("café Café", "café", false); + assert_eq!(hits.len(), 2); + let (s0, e0) = hits[0]; + let (s1, e1) = hits[1]; + assert_eq!(&"café Café"[s0..e0], "café"); + assert_eq!(&"café Café"[s1..e1], "Café"); + } + + #[test] + fn replace_all_replaces_every_match() { + assert_eq!( + replace_all("the cat sat", "at", "AT", true), + ("the cAT sAT".to_string(), 2) + ); + // Case-insensitive replacement across mixed case. + assert_eq!( + replace_all("Cat cat", "cat", "dog", false), + ("dog dog".to_string(), 2) + ); + // No matches leaves the text untouched. + assert_eq!( + replace_all("hello", "zz", "!", true), + ("hello".to_string(), 0) + ); + // Replacing with a longer/shorter string doesn't corrupt boundaries. + assert_eq!( + replace_all("a.b.c", ".", " - ", true), + ("a - b - c".to_string(), 2) + ); + } +} diff --git a/src/app/grammar.rs b/src/app/grammar.rs new file mode 100644 index 0000000..9603cf9 --- /dev/null +++ b/src/app/grammar.rs @@ -0,0 +1,484 @@ +//! LanguageTool grammar checking: firing background checks, polling their +//! results, the issues panel, the server settings dialog, and applying a +//! suggested replacement back into the buffer. + +use super::*; + +impl App { + /// Discard any grammar-check results (they belong to a specific buffer). + pub(super) fn clear_lt(&mut self) { + self.lt_matches.clear(); + self.lt_checked_text.clear(); + self.lt_status.clear(); + } + + /// Kick off a LanguageTool check of the current buffer on a background + /// thread. Results are delivered through `lt_rx` and picked up in `update`. + pub(super) fn start_lt_check(&mut self, ctx: &egui::Context) { + if self.selected.is_none() { + self.lt_status = "Open a file to check".to_string(); + return; + } + if self.lt_rx.is_some() { + return; // a check is already running + } + if self.config.languagetool_host.trim().is_empty() { + self.lt_status = "Set a LanguageTool host in Settings first".to_string(); + self.show_lt_panel = true; + self.show_settings = true; + return; + } + let url = self.config.languagetool_base_url(); + let token = self.config.languagetool_token.clone(); + let mut language = self.config.languagetool_language.trim().to_string(); + if language.is_empty() { + language = "auto".to_string(); + } + let text = self.buffer.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + self.lt_rx = Some(rx); + self.lt_status = "Checking…".to_string(); + self.show_lt_panel = true; + let ctx = ctx.clone(); + std::thread::spawn(move || { + let result = crate::langtool::check(&url, &language, &token, &text); + let _ = tx.send((text, result)); + ctx.request_repaint(); + }); + } + + /// Ping the configured server with a sample sentence to confirm the settings + /// work, without disturbing the editor's current results. Used by the + /// Settings dialog's "Test connection" button. + pub(super) fn test_lt_connection(&mut self, ctx: &egui::Context) { + if self.settings_test_rx.is_some() { + return; + } + let url = self.config.languagetool_base_url(); + let token = self.config.languagetool_token.clone(); + let language = { + let l = self.config.languagetool_language.trim(); + if l.is_empty() { "auto".to_string() } else { l.to_string() } + }; + let (tx, rx) = std::sync::mpsc::channel(); + self.settings_test_rx = Some(rx); + self.settings_test_status = "Testing…".to_string(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + // A sentence with a deliberate error, so a working server returns ≥1 match. + let result = + crate::langtool::check(&url, &language, &token, "The team are ready to began."); + let _ = tx.send(result.map(|m| m.len())); + ctx.request_repaint(); + }); + } + + /// Pick up a finished "Test connection" result for the Settings dialog. + pub(super) fn poll_settings_test(&mut self) { + let received = self + .settings_test_rx + .as_ref() + .and_then(|rx| rx.try_recv().ok()); + if let Some(result) = received { + self.settings_test_rx = None; + self.settings_test_status = match result { + Ok(_) => format!("✔ Connected to {}", self.config.languagetool_base_url()), + Err(e) => format!("✖ {e}"), + }; + } + } + + /// Poll for a finished background check and store its results. + pub(super) fn poll_lt(&mut self) { + let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some((text, result)) = received { + self.lt_rx = None; + match result { + Ok(matches) => { + self.lt_status = match matches.len() { + 0 => "No issues found".to_string(), + 1 => "1 issue".to_string(), + n => format!("{n} issues"), + }; + self.lt_matches = matches; + self.lt_checked_text = text; + } + Err(e) => { + self.lt_matches.clear(); + self.lt_checked_text.clear(); + self.lt_status = e; + } + } + } + } + + /// Apply a LanguageTool suggestion to the buffer, then shift the remaining + /// matches so their highlights stay valid. + pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) { + // Only safe while the buffer still matches what was checked. + if self.buffer != self.lt_checked_text { + return; + } + if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) { + self.dirty = true; + self.find_needs_refresh = true; + self.lt_checked_text = self.buffer.clone(); + self.lt_status = match self.lt_matches.len() { + 0 => "No issues remaining".to_string(), + 1 => "1 issue".to_string(), + n => format!("{n} issues"), + }; + } + } + + /// Apply suggestion `rep_idx` of the match at `match_idx` from whichever + /// source currently owns the issues (LanguageTool or the spell checker). + pub(super) fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) { + match self.issue_source() { + IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx), + IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx), + IssueSource::None => {} + } + } + + /// True when LanguageTool results describe the current buffer exactly (so + /// they, rather than the live spell checker, own the underlines). + pub(super) fn lt_is_current(&self) -> bool { + !self.lt_checked_text.is_empty() && self.buffer == self.lt_checked_text + } + + /// Which check currently owns the issues shown in the editor and panel. + pub(super) fn issue_source(&self) -> IssueSource { + if self.lt_is_current() { + IssueSource::LanguageTool + } else if self.config.spell_check + && self.spell_dict.is_some() + && self.buffer == self.spell_checked_text + { + IssueSource::Spell + } else { + IssueSource::None + } + } + + /// The matches currently owning the editor's underlines (may be empty). + pub(super) fn current_matches(&self) -> &[crate::langtool::Match] { + match self.issue_source() { + IssueSource::LanguageTool => &self.lt_matches, + IssueSource::Spell => &self.spell_matches, + IssueSource::None => &[], + } + } + + /// Index of the current match covering byte offset `byte`, if any. + pub(super) fn match_index_at(&self, byte: usize) -> Option { + self.current_matches() + .iter() + .position(|m| byte >= m.start && byte < m.end) + } + + /// An owned snapshot of the current issues for the results panel. + pub(super) fn issue_items(&self) -> (IssueSource, Vec) { + let source = self.issue_source(); + let (matches, checked) = match source { + IssueSource::LanguageTool => (&self.lt_matches, self.lt_checked_text.as_str()), + IssueSource::Spell => (&self.spell_matches, self.spell_checked_text.as_str()), + IssueSource::None => return (source, Vec::new()), + }; + let items = matches + .iter() + .enumerate() + .map(|(idx, m)| IssueItem { + idx, + snippet: checked.get(m.start..m.end).unwrap_or("").to_string(), + message: m.message.clone(), + spelling: m.spelling, + replacements: m.replacements.clone(), + }) + .collect(); + (source, items) + } + + /// Floating window for editing the LanguageTool connection settings. + pub(super) fn settings_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_settings; + let mut close_clicked = false; + egui::Window::new("LanguageTool settings") + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + let mut save_now = false; + egui::Grid::new("lt_settings_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("Scheme:"); + egui::ComboBox::from_id_salt("lt_scheme") + .selected_text(self.config.languagetool_scheme.clone()) + .show_ui(ui, |ui| { + for s in ["http", "https"] { + if ui + .selectable_value( + &mut self.config.languagetool_scheme, + s.to_string(), + s, + ) + .clicked() + { + save_now = true; + } + } + }); + ui.end_row(); + + ui.label("Host / domain:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.languagetool_host) + .hint_text("localhost or lt.example.com") + .desired_width(230.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Port:"); + let r = ui.add( + egui::DragValue::new(&mut self.config.languagetool_port) + .speed(1.0) + .range(1..=65535), + ); + save_now |= r.drag_stopped() || r.lost_focus(); + ui.end_row(); + + ui.label("Token:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.languagetool_token) + .password(true) + .hint_text("optional — auth proxy / API key") + .desired_width(230.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + + ui.label("Language:"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.config.languagetool_language) + .hint_text("auto, en-US, de-DE…") + .desired_width(120.0), + ); + save_now |= r.lost_focus(); + ui.end_row(); + }); + + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "Endpoint: {}/v2/check", + self.config.languagetool_base_url() + )) + .weak() + .monospace(), + ); + ui.separator(); + + ui.horizontal(|ui| { + let testing = self.settings_test_rx.is_some(); + if ui + .add_enabled(!testing, egui::Button::new("Test connection")) + .clicked() + { + self.config.save(); + self.test_lt_connection(ctx); + } + if ui.button("Close").clicked() { + close_clicked = true; + } + }); + if !self.settings_test_status.is_empty() { + ui.label(&self.settings_test_status); + } + + if save_now { + self.config.save(); + } + }); + + let now_open = open && !close_clicked; + if self.show_settings && !now_open { + // Window is closing — persist and reset its transient state. + self.config.save(); + self.settings_test_status.clear(); + } + self.show_settings = now_open; + } + + /// Bottom panel listing the current grammar/spelling issues with one-click + /// fixes. Shows LanguageTool results when they're fresh, otherwise the live + /// spell-check results. + pub(super) fn lt_panel(&mut self, ctx: &egui::Context) { + let (source, items) = self.issue_items(); + egui::TopBottomPanel::bottom("ltpanel") + .resizable(true) + .default_height(190.0) + .show(ctx, |ui| { + let (title, status) = match source { + IssueSource::LanguageTool => ("Grammar & spelling", self.lt_status.clone()), + _ => ("Spelling", self.spell_status.clone()), + }; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(title).strong()); + if !status.is_empty() { + ui.label(egui::RichText::new(status).weak()); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Hide").clicked() { + self.show_lt_panel = false; + } + }); + }); + ui.separator(); + + if items.is_empty() { + let busy = self.lt_rx.is_some() || self.spell_rx.is_some(); + ui.label( + egui::RichText::new(if busy { "Checking…" } else { "No issues to show." }) + .weak(), + ); + return; + } + + let mut apply: Option<(usize, usize)> = None; + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for item in &items { + ui.horizontal_wrapped(|ui| { + let col = if item.spelling { + egui::Color32::from_rgb(0xE0, 0x40, 0x40) + } else { + egui::Color32::from_rgb(0x3B, 0x82, 0xF6) + }; + ui.label(egui::RichText::new("●").color(col)); + if !item.snippet.is_empty() { + ui.label( + egui::RichText::new(format!("“{}”", item.snippet)).strong(), + ); + } + ui.label(&item.message); + }); + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + if item.replacements.is_empty() { + ui.label( + egui::RichText::new("(no suggestion)").weak().italics(), + ); + } else { + for (j, rep) in item.replacements.iter().enumerate() { + if ui.button(egui::RichText::new(rep).small()).clicked() { + apply = Some((item.idx, j)); + } + } + } + }); + ui.separator(); + } + }); + + if let Some((i, j)) = apply { + self.apply_current_fix(i, j); + } + }); + } +} + +/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and +/// remap the remaining matches. Returns whether a replacement was made (false if +/// the indices are out of range or the match's bounds aren't valid boundaries). +pub(super) fn splice_fix( + buffer: &mut String, + matches: &mut Vec, + match_idx: usize, + rep_idx: usize, +) -> bool { + let Some(m) = matches.get(match_idx).cloned() else { + return false; + }; + let Some(replacement) = m.replacements.get(rep_idx).cloned() else { + return false; + }; + if m.end > buffer.len() + || !buffer.is_char_boundary(m.start) + || !buffer.is_char_boundary(m.end) + { + return false; + } + buffer.replace_range(m.start..m.end, &replacement); + // The applied match overlaps its own region, so it is dropped here too. + remap_matches(matches, m.start, m.end, replacement.len()); + true +} + +/// Re-position matches after the byte range `[s, e)` was replaced with `new_len` +/// bytes. Matches that overlapped the edited region (including the one that was +/// just applied) are dropped; matches entirely after it are shifted by the +/// length delta so their highlights and offsets stay valid. +pub(super) fn remap_matches( + matches: &mut Vec, + s: usize, + e: usize, + new_len: usize, +) { + matches.retain(|o| o.end <= s || o.start >= e); + let delta = new_len as i64 - (e - s) as i64; + for o in matches.iter_mut() { + if o.start >= e { + o.start = (o.start as i64 + delta) as usize; + o.end = (o.end as i64 + delta) as usize; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(start: usize, end: usize) -> crate::langtool::Match { + crate::langtool::Match { + start, + end, + message: String::new(), + replacements: Vec::new(), + spelling: false, + } + } + + #[test] + fn remap_shifts_later_and_drops_overlapping() { + // Text "aaaa BBB cccc dddd"; replace "BBB" (5..8, len 3) with "XX" (len 2). + let mut matches = vec![m(0, 4), m(5, 8), m(9, 13), m(14, 18)]; + remap_matches(&mut matches, 5, 8, 2); + // The applied match (5..8) is dropped; the one before is untouched; + // the two after shift left by 1. + assert_eq!(matches.len(), 3); + assert_eq!((matches[0].start, matches[0].end), (0, 4)); + assert_eq!((matches[1].start, matches[1].end), (8, 12)); + assert_eq!((matches[2].start, matches[2].end), (13, 17)); + } + + #[test] + fn remap_grows_when_replacement_is_longer() { + let mut matches = vec![m(0, 3), m(10, 14)]; + // Replace [0,3) (len 3) with 5 bytes: delta +2 shifts the later match. + remap_matches(&mut matches, 0, 3, 5); + assert_eq!(matches.len(), 1); + assert_eq!((matches[0].start, matches[0].end), (12, 16)); + } + + #[test] + fn remap_drops_matches_overlapping_the_edit() { + // A match straddling the edit boundary is discarded, not mis-shifted. + let mut matches = vec![m(2, 7)]; + remap_matches(&mut matches, 5, 8, 1); + assert!(matches.is_empty()); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..a1776e7 --- /dev/null +++ b/src/app/mod.rs @@ -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, 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); + +/// 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, +} + +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 { + 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, + /// Ordered markdown file names (relative to the workspace). + files: Vec, + /// Per-file chapter title overrides (file name -> title). Missing/empty means + /// the title is derived automatically at export time. + titles: HashMap, + /// Editing buffer for the selected file's chapter title override. + title_input: String, + selected: Option, + /// 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, + /// 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, + /// 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, + /// Index (into the currently displayed matches) of the word a right-click + /// suggestion menu is open for, if any. + spell_menu: Option, + /// Whether the Mistral settings window is open. + show_mistral_settings: bool, + /// In-flight background plot-beat generation, if any. + beats_rx: Option>>, + /// 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, +} + +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(); + } +} diff --git a/src/app/spelling.rs b/src/app/spelling.rs new file mode 100644 index 0000000..b7efd0d --- /dev/null +++ b/src/app/spelling.rs @@ -0,0 +1,137 @@ +//! Offline spell checking: loading a bundled or system Hunspell dictionary, +//! running checks in the background, and applying a correction. + +use super::*; + +impl App { + /// Apply a spell-check suggestion to the buffer, keeping the remaining + /// misspelling underlines aligned. + pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) { + if self.buffer != self.spell_checked_text { + return; + } + if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) { + self.dirty = true; + self.find_needs_refresh = true; + // Keep these matches valid without forcing a full re-check. + self.spell_checked_text = self.buffer.clone(); + self.spell_status = match self.spell_matches.len() { + 0 => "No spelling issues".to_string(), + 1 => "1 spelling issue".to_string(), + n => format!("{n} spelling issues"), + }; + } + } + + // ---- Spell check (offline) --------------------------------------------- + + /// (Re)load the dictionary named by `config.spell_language`, falling back to + /// the default and then to whatever is available. Resets the live results so + /// the buffer is re-checked against the new dictionary. + pub(super) fn load_spell_dict(&mut self) { + let want = self.config.spell_language.clone(); + let entry = self + .spell_dicts + .iter() + .find(|d| d.id == want) + .or_else(|| { + self.spell_dicts + .iter() + .find(|d| d.id == crate::spell::DEFAULT_LANGUAGE) + }) + .or_else(|| self.spell_dicts.first()) + .cloned(); + + match entry { + Some(e) => match e.load() { + Ok(dict) => { + self.spell_dict = Some(Arc::new(dict)); + self.spell_status = format!("Dictionary: {}", e.id); + } + Err(err) => { + self.spell_dict = None; + self.spell_status = err; + } + }, + None => { + self.spell_dict = None; + self.spell_status = "No spelling dictionary available".to_string(); + } + } + self.spell_matches.clear(); + self.spell_checked_text.clear(); + self.spell_dirty = true; + self.spell_last_edit = None; + } + + /// Switch the active dictionary and persist the choice. + pub(super) fn set_spell_language(&mut self, id: &str) { + if self.config.spell_language == id { + return; + } + self.config.spell_language = id.to_string(); + self.config.save(); + self.load_spell_dict(); + } + + /// The status line for the spelling checker given a match count. + pub(super) fn spell_count_status(n: usize) -> String { + match n { + 0 => "No spelling issues".to_string(), + 1 => "1 spelling issue".to_string(), + n => format!("{n} spelling issues"), + } + } + + /// Pick up a finished background spell check. + pub(super) fn poll_spell(&mut self) { + let received = self.spell_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some((text, matches)) = received { + self.spell_rx = None; + if self.spell_dict.is_some() { + self.spell_status = Self::spell_count_status(matches.len()); + } + self.spell_matches = matches; + self.spell_checked_text = text; + } + } + + /// Start a background spell check of the current buffer if one is due: + /// enabled, a dictionary loaded, a file open, the buffer changed, no check + /// already running, and the debounce interval elapsed since the last edit. + pub(super) fn maybe_start_spell_check(&mut self, ctx: &egui::Context) { + if !self.config.spell_check || self.selected.is_none() || !self.spell_dirty { + return; + } + // LanguageTool results already cover this exact buffer (spelling too), so + // don't spend effort on a spell pass that wouldn't be shown. + if self.lt_is_current() { + return; + } + let Some(dict) = self.spell_dict.clone() else { + return; + }; + if self.spell_rx.is_some() { + return; + } + // Debounce so we don't re-check on every keystroke. + const DEBOUNCE: Duration = Duration::from_millis(400); + if let Some(edited) = self.spell_last_edit { + let elapsed = edited.elapsed(); + if elapsed < DEBOUNCE { + ctx.request_repaint_after(DEBOUNCE - elapsed); + return; + } + } + self.spell_dirty = false; + let text = self.buffer.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + self.spell_rx = Some(rx); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let matches = crate::spell::check(&dict, &text); + let _ = tx.send((text, matches)); + ctx.request_repaint(); + }); + } +} diff --git a/src/app/style.rs b/src/app/style.rs new file mode 100644 index 0000000..4d296ae --- /dev/null +++ b/src/app/style.rs @@ -0,0 +1,116 @@ +//! Global egui styling: font scaling, list/editor contrast tuning and the +//! colour helpers they use. + +use super::*; + +/// Fraction each base font size is enlarged, applied once at startup so every +/// panel's text is slightly bigger without changing layout maths elsewhere. +pub(super) const FONT_SCALE: f32 = 1.12; + +/// Configure the shared context style once at startup: raise the contrast of +/// *all* UI text (menus, top bar, buttons, panels, headings — everything, not +/// just the file list and editor) and enlarge every base font size slightly. +/// +/// The default dark theme uses fairly dim greys (~140) for body text, the main +/// legibility complaint; we pull each widget state's foreground toward white in +/// dark mode / black in light mode. Explicitly coloured spans (grammar +/// underlines, the cheatsheet's syntax colour) set their own colours and are +/// unaffected. The per-pane boosts (`boost_list_contrast`, the editor contrast +/// slider) still layer on top for their specific widgets. +pub(super) fn apply_global_style(ctx: &egui::Context) { + let mut style = (*ctx.style()).clone(); + + // Enlarge every text style (Body, Button, Heading, Monospace, Small). + for font in style.text_styles.values_mut() { + font.size = (font.size * FONT_SCALE).round(); + } + + let v = &mut style.visuals; + let target = if v.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + let w = &mut v.widgets; + w.noninteractive.fg_stroke.color = + lerp_color(w.noninteractive.fg_stroke.color, target, 0.55); + w.inactive.fg_stroke.color = lerp_color(w.inactive.fg_stroke.color, target, 0.45); + w.hovered.fg_stroke.color = lerp_color(w.hovered.fg_stroke.color, target, 0.35); + w.active.fg_stroke.color = lerp_color(w.active.fg_stroke.color, target, 0.35); + w.open.fg_stroke.color = lerp_color(w.open.fg_stroke.color, target, 0.45); + + ctx.set_style(style); +} + +/// Raise the text contrast of list-style widgets in the current `ui`, so file +/// names in the left pane stay legible against the panel background. Only the +/// widget foreground colours are touched; layout and the rest of the app are +/// unaffected (the change is scoped to the panel that calls this). +pub(super) fn boost_list_contrast(visuals: &mut egui::Visuals) { + if visuals.dark_mode { + // Unselected rows, hover, and the pressed state → toward white. + visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(235); + visuals.widgets.hovered.fg_stroke.color = egui::Color32::WHITE; + visuals.widgets.active.fg_stroke.color = egui::Color32::WHITE; + // Selected-row text (SelectableLabel uses selection.stroke when selected). + visuals.selection.stroke.color = egui::Color32::WHITE; + } else { + visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(20); + visuals.widgets.hovered.fg_stroke.color = egui::Color32::BLACK; + visuals.widgets.active.fg_stroke.color = egui::Color32::BLACK; + visuals.selection.stroke.color = egui::Color32::WHITE; + } +} + +/// The editor's text colour, interpolated from the theme's default toward the +/// maximum-contrast colour (white in dark mode, black in light mode) by +/// `contrast` percent (0 = theme default, 100 = full contrast). +pub(super) fn editor_text_color(visuals: &egui::Visuals, contrast: f32) -> egui::Color32 { + let base = visuals.text_color(); + let target = if visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + lerp_color(base, target, contrast / 100.0) +} + +/// Linearly interpolate between two colours in gamma (sRGB byte) space. +/// `t` is clamped to `[0, 1]`; `t = 0` yields `a`, `t = 1` yields `b`. +pub(super) fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 { + let t = t.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8; + egui::Color32::from_rgb( + mix(a.r(), b.r()), + mix(a.g(), b.g()), + mix(a.b(), b.b()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lerp_color_hits_endpoints_and_midpoint() { + let a = egui::Color32::from_rgb(0, 0, 0); + let b = egui::Color32::from_rgb(200, 100, 50); + assert_eq!(lerp_color(a, b, 0.0), a); + assert_eq!(lerp_color(a, b, 1.0), b); + assert_eq!(lerp_color(a, b, 0.5), egui::Color32::from_rgb(100, 50, 25)); + // t is clamped to [0, 1]. + assert_eq!(lerp_color(a, b, -1.0), a); + assert_eq!(lerp_color(a, b, 2.0), b); + } + + #[test] + fn editor_contrast_moves_toward_white_in_dark_mode() { + let dark = egui::Visuals::dark(); + // 0% keeps the theme's text colour; 100% is fully white. + assert_eq!(editor_text_color(&dark, 0.0), dark.text_color()); + assert_eq!(editor_text_color(&dark, 100.0), egui::Color32::WHITE); + // In light mode, full contrast is black instead. + let light = egui::Visuals::light(); + assert_eq!(editor_text_color(&light, 100.0), egui::Color32::BLACK); + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs new file mode 100644 index 0000000..13281ad --- /dev/null +++ b/src/app/ui.rs @@ -0,0 +1,493 @@ +//! Top-level window chrome: the menu bar, the toolbar, the central editor +//! panel, the markdown preview and the repo-adoption prompt. + +use super::*; + +impl App { + // ---- UI ---------------------------------------------------------------- + + pub(super) fn top_bar(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::top("top").show(ctx, |ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Workspace:"); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.workspace_input) + .desired_width(300.0), + ); + if ui.button("📂").on_hover_text("Browse for workspace folder").clicked() { + self.browse_workspace(); + } + if ui.button("Open").clicked() + || (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + self.save_current(); + self.config.workspace = PathBuf::from(self.workspace_input.trim()); + self.config.save(); + self.open_workspace(); + } + ui.separator(); + if self.is_repo { + let hover = match &self.repo_root { + Some(root) if root.as_path() != self.workspace() => { + format!("Commit & push to the repository at {}", root.display()) + } + _ => "Commit & push this workspace's repository".to_string(), + }; + if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() { + self.git_sync(); + } + } else if ui.button("Init git").clicked() { + self.git_init(); + } + if ui.button("Log").clicked() { + self.show_log = !self.show_log; + } + }); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label("Export:"); + ui.add( + egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0), + ); + if ui.button("📂").on_hover_text("Browse for export .odt file").clicked() { + self.browse_export(); + } + if ui.button("Export ODT").clicked() { + self.export_odt(); + } + ui.separator(); + if ui + .checkbox(&mut self.config.show_preview, "Preview") + .changed() + { + self.config.save(); + } + ui.separator(); + ui.label("Draft marker:") + .on_hover_text( + "Text above this line in each file (its header) is dropped on export; \ + # Title: / # Slug: lines become the chapter heading and caption. \ + Leave blank to export files whole.", + ); + let marker_resp = ui.add( + egui::TextEdit::singleline(&mut self.config.draft_marker) + .desired_width(140.0), + ); + if marker_resp.lost_focus() { + self.config.save(); + } + ui.separator(); + if ui + .checkbox(&mut self.config.zero_pad_index, "Zero-pad #") + .on_hover_text( + "When a chapter title defaults to its number, pad it with \ + leading zeros (e.g. 03. of 12).", + ) + .changed() + { + self.config.save(); + } + }); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label("Grammar:").on_hover_text( + "Grammar & spelling via a LanguageTool server. \ + Configure it in Settings ▸ LanguageTool.", + ); + let checking = self.lt_rx.is_some(); + if ui + .add_enabled( + !checking && self.selected.is_some(), + egui::Button::new("✓ Check"), + ) + .on_hover_text("Check the current file's grammar and spelling") + .clicked() + { + self.start_lt_check(ctx); + } + if !self.lt_matches.is_empty() + && ui.button("Clear").on_hover_text("Clear check results").clicked() + { + self.clear_lt(); + } + if ui + .button("⚙") + .on_hover_text("LanguageTool settings") + .clicked() + { + self.show_settings = true; + } + if !self.lt_status.is_empty() { + ui.label(egui::RichText::new(&self.lt_status).weak()); + } + }); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label("Spelling:").on_hover_text( + "Offline spell check (Hunspell dictionaries). Underlines misspellings \ + as you type when LanguageTool results aren't current; right-click a \ + word for suggestions.", + ); + if ui + .checkbox(&mut self.config.spell_check, "Check as I type") + .on_hover_text("Underline misspellings live using the dictionary below") + .changed() + { + self.config.save(); + if self.config.spell_check { + // Re-check the current buffer straight away. + self.spell_dirty = true; + self.spell_last_edit = None; + } + } + ui.label("Dictionary:"); + let current = self.config.spell_language.clone(); + let mut pick: Option = None; + egui::ComboBox::from_id_salt("spell_dict") + .selected_text(current.clone()) + .show_ui(ui, |ui| { + for d in &self.spell_dicts { + if ui + .selectable_label(current == d.id, &d.label) + .clicked() + { + pick = Some(d.id.clone()); + } + } + }); + if let Some(id) = pick { + self.set_spell_language(&id); + } + if !self.spell_status.is_empty() { + ui.label(egui::RichText::new(&self.spell_status).weak()); + } + }); + ui.add_space(4.0); + }); + + egui::TopBottomPanel::bottom("status").show(ctx, |ui| { + ui.add_space(2.0); + ui.horizontal(|ui| { + let dirty = if self.dirty { " • unsaved" } else { "" }; + ui.label(format!("{}{dirty}", self.status)); + + let goal = self.current_goal(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(idx) = self.selected { + let name = &self.files[idx]; + let total = count_words(&self.buffer); + let baseline = + self.session_start_counts.get(name).copied().unwrap_or(0); + let delta = total as i64 - baseline as i64; + let sign = if delta < 0 { "-" } else { "+" }; + ui.label( + egui::RichText::new(format!( + "{} words · {sign}{} this session", + thousands(total), + thousands(delta.unsigned_abs() as usize), + )) + .weak(), + ) + .on_hover_text( + "Words in the current file · net change since this session opened", + ); + + // Progress toward the file's Word Count Target, if set. + if let Some((goal, prose)) = goal { + let (frac, color, text) = goal_progress(goal, prose); + ui.separator(); + ui.add( + egui::ProgressBar::new(frac) + .desired_width(150.0) + .fill(color) + .text(egui::RichText::new(text).small()), + ) + .on_hover_text( + "Prose words vs the file's “Word Count Target” header \ + (counts the body below the draft marker). Amber = under \ + target, green = in range, blue = over.", + ); + } + } + }); + }); + ui.add_space(2.0); + }); + } + + /// The application menu bar (File / View / Settings). + pub(super) fn menu_bar(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::top("menubar").show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + ui.menu_button("File", |ui| { + if ui.button("📂 Open workspace…").clicked() { + ui.close_menu(); + self.browse_workspace(); + } + if ui.button("Export ODT").clicked() { + ui.close_menu(); + self.export_odt(); + } + ui.separator(); + if ui.button("Quit").clicked() { + ui.close_menu(); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + ui.menu_button("Edit", |ui| { + if ui.button("🔍 Find / Replace…").clicked() { + ui.close_menu(); + self.open_find(true); + } + }); + ui.menu_button("Tools", |ui| { + let busy = self.beats_rx.is_some(); + if ui + .add_enabled( + !busy, + egui::Button::new("✨ Generate plot beats (Mistral)…"), + ) + .clicked() + { + ui.close_menu(); + self.generate_plot_beats(ctx); + } + }); + ui.menu_button("View", |ui| { + if ui + .checkbox(&mut self.config.show_preview, "Preview pane") + .clicked() + { + self.config.save(); + } + ui.checkbox(&mut self.show_lt_panel, "Grammar panel"); + ui.checkbox(&mut self.show_log, "Git log"); + }); + ui.menu_button("Settings", |ui| { + if ui.button("LanguageTool…").clicked() { + ui.close_menu(); + self.show_settings = true; + } + if ui.button("Mistral…").clicked() { + ui.close_menu(); + self.show_mistral_settings = true; + } + }); + ui.menu_button("Help", |ui| { + if ui.button("📝 Markdown cheatsheet").clicked() { + ui.close_menu(); + self.show_cheatsheet = true; + } + }); + }); + }); + } + + pub(super) fn central(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| { + match self.selected { + Some(idx) => { + let name = self.files[idx].clone(); + ui.horizontal(|ui| { + ui.heading(&name); + if ui.button("💾 Save").clicked() { + self.dirty = true; // ensure save runs + self.save_current(); + } + ui.separator(); + ui.label("Zoom:"); + if ui + .add( + egui::Slider::new(&mut self.config.editor_zoom, -50.0..=200.0) + .suffix("%") + .step_by(5.0), + ) + .on_hover_text("Editor text size (0% = default)") + .changed() + { + self.config.save(); + } + if ui + .button("Reset") + .on_hover_text("Reset zoom to 0%") + .clicked() + { + self.config.editor_zoom = 0.0; + self.config.save(); + } + ui.separator(); + ui.label("Contrast:"); + if ui + .add( + egui::Slider::new( + &mut self.config.editor_text_contrast, + 0.0..=100.0, + ) + .suffix("%") + .step_by(5.0), + ) + .on_hover_text( + "Editor text contrast (0% = theme default; higher \ + brightens the text in dark mode)", + ) + .changed() + { + self.config.save(); + } + }); + ui.horizontal(|ui| { + ui.label("Chapter title:"); + let auto = self.auto_title(idx, &self.buffer); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.title_input) + .hint_text(format!("auto: {auto}")) + .desired_width(320.0), + ); + if resp.changed() { + self.set_title_for_current(); + } + ui.label( + egui::RichText::new("used as the ODT chapter heading") + .small() + .weak(), + ); + }); + ui.separator(); + + if self.show_find { + self.find_bar(ui); + } + + if self.config.show_preview { + // Editor + read-only source-ish preview side by side. + let full = ui.available_size(); + ui.horizontal_top(|ui| { + let col_w = full.x / 2.0 - 6.0; + ui.allocate_ui(egui::vec2(col_w, full.y), |ui| { + self.editor(ui); + }); + ui.separator(); + ui.allocate_ui(egui::vec2(col_w, full.y), |ui| { + egui::ScrollArea::vertical() + .id_salt("preview") + .auto_shrink([false, false]) + .show(ui, |ui| { + render_preview(ui, &self.buffer); + }); + }); + }); + } else { + self.editor(ui); + } + } + None => { + ui.centered_and_justified(|ui| { + ui.label("Select a file on the left, or create a new one."); + }); + } + } + }); + } + + /// Ask the user whether to adopt a git repository found in a parent + /// directory of the freshly opened workspace. + pub(super) fn repo_prompt(&mut self, ctx: &egui::Context) { + let Some(root) = self.pending_repo.clone() else { + return; + }; + let mut adopt = false; + let mut decline = false; + egui::Window::new("Use enclosing git repository?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label( + "This folder isn't a git repository, but one was found in a \ + parent directory:", + ); + ui.add_space(4.0); + ui.label(egui::RichText::new(root.display().to_string()).strong()); + ui.add_space(4.0); + ui.label( + "Use it for version control (Sync commits and pushes to this \ + repo)? Otherwise the workspace is left without git; you can \ + still Init a separate repository here.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Use this repository").clicked() { + adopt = true; + } + if ui.button("No, keep separate").clicked() { + decline = true; + } + }); + }); + if adopt { + self.adopt_pending_repo(); + } else if decline { + self.decline_pending_repo(); + } + } +} + +/// Very small markdown-ish preview (headings emphasised, everything else plain). +pub(super) fn render_preview(ui: &mut egui::Ui, markdown: &str) { + use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + let parser = Parser::new_ext(markdown, Options::ENABLE_STRIKETHROUGH); + let mut heading: Option = None; + let mut line = String::new(); + let mut bold = false; + let mut italic = false; + + let flush = |ui: &mut egui::Ui, line: &mut String, heading: &mut Option| { + if line.trim().is_empty() { + line.clear(); + *heading = None; + return; + } + let text = line.clone(); + match heading { + Some(HeadingLevel::H1) => { + ui.label(egui::RichText::new(text).size(22.0).strong()); + } + Some(HeadingLevel::H2) => { + ui.label(egui::RichText::new(text).size(18.0).strong()); + } + Some(_) => { + ui.label(egui::RichText::new(text).size(15.0).strong()); + } + None => { + ui.label(text); + } + } + line.clear(); + *heading = None; + }; + + for event in parser { + match event { + Event::Start(Tag::Heading { level, .. }) => heading = Some(level), + Event::End(TagEnd::Heading(_)) => flush(ui, &mut line, &mut heading), + Event::End(TagEnd::Paragraph) => flush(ui, &mut line, &mut heading), + Event::Start(Tag::Item) => line.push_str("• "), + Event::End(TagEnd::Item) => flush(ui, &mut line, &mut heading), + Event::Start(Tag::Strong) => bold = true, + Event::End(TagEnd::Strong) => bold = false, + Event::Start(Tag::Emphasis) => italic = true, + Event::End(TagEnd::Emphasis) => italic = false, + Event::Text(t) | Event::Code(t) => { + let _ = (bold, italic); + line.push_str(&t); + } + Event::SoftBreak | Event::HardBreak => line.push(' '), + Event::Rule => { + ui.separator(); + } + _ => {} + } + } + flush(ui, &mut line, &mut heading); +} diff --git a/src/app/util.rs b/src/app/util.rs new file mode 100644 index 0000000..c564fc1 --- /dev/null +++ b/src/app/util.rs @@ -0,0 +1,54 @@ +//! Small formatting helpers shared across the UI modules. + +/// Count words in a string: runs of non-whitespace separated by whitespace. +pub(super) fn count_words(s: &str) -> usize { + s.split_whitespace().count() +} + +/// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345"). +pub(super) fn thousands(n: usize) -> String { + let digits = n.to_string(); + let len = digits.len(); + let mut out = String::with_capacity(len + len / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (len - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out +} + +/// A dependency-free timestamp for commit messages (UTC seconds since epoch). +pub(super) fn chrono_like_timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("@{secs}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_words_across_whitespace() { + assert_eq!(count_words(""), 0); + assert_eq!(count_words(" \n\t "), 0); + assert_eq!(count_words("one"), 1); + assert_eq!(count_words("one two three"), 3); + assert_eq!(count_words(" spread \n over\tlines "), 3); + } + + #[test] + fn formats_thousands_separators() { + assert_eq!(thousands(0), "0"); + assert_eq!(thousands(42), "42"); + assert_eq!(thousands(999), "999"); + assert_eq!(thousands(1_000), "1,000"); + assert_eq!(thousands(12_345), "12,345"); + assert_eq!(thousands(1_234_567), "1,234,567"); + } +} diff --git a/src/app/workspace.rs b/src/app/workspace.rs new file mode 100644 index 0000000..3f5e160 --- /dev/null +++ b/src/app/workspace.rs @@ -0,0 +1,473 @@ +//! Workspace, file-list and git operations: opening a folder, creating, +//! renaming and deleting manuscript files, persisting order and titles, and +//! exporting the assembled manuscript to ODT. + +use super::*; + +impl App { + pub(super) fn workspace(&self) -> &Path { + &self.config.workspace + } + + /// (Re)load the file list for the current workspace, creating the directory + /// if needed, and refresh git status. + pub(super) fn open_workspace(&mut self) { + let ws = self.config.workspace.clone(); + if let Err(e) = std::fs::create_dir_all(&ws) { + self.status = format!("Cannot create workspace: {e}"); + return; + } + self.files = order::resolve_order(&ws); + self.titles = order::read_titles(&ws); + // Drop overrides for files that no longer exist. + self.titles.retain(|name, _| self.files.contains(name)); + self.detect_repo(&ws); + self.selected = None; + self.buffer.clear(); + self.dirty = false; + self.pending_delete = false; + self.clear_lt(); + self.session_start_counts = self.snapshot_counts(); + self.file_meta = self.snapshot_file_meta(); + self.rebuild_field_names(); + self.autocomplete = None; + if !self.files.is_empty() { + self.select(0); + } + self.persist_order(); + self.status = format!("{} file(s) in {}", self.files.len(), ws.display()); + } + + /// Work out the git situation for a freshly opened workspace. + /// + /// If the folder is itself a repository root it is adopted silently. If it + /// is not, but an enclosing parent directory is a git work tree, we stash + /// that parent in `pending_repo` and ask the user to confirm before using + /// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is + /// treated as having no repository. + pub(super) fn detect_repo(&mut self, ws: &Path) { + self.pending_repo = None; + // A `.git` entry directly in the folder (dir, or a file for linked + // worktrees/submodules) means this folder is the repo root. + if ws.join(".git").exists() { + self.is_repo = true; + self.repo_root = Some(ws.to_path_buf()); + return; + } + match gitsync::repo_root(ws) { + // A parent directory is a repository — ask before adopting it. + Some(root) if root != ws => { + self.is_repo = false; + self.repo_root = None; + self.pending_repo = Some(root); + } + // `--show-toplevel` reported this very folder (shouldn't happen + // without a `.git` here, but treat it as an ordinary repo root). + Some(root) => { + self.is_repo = true; + self.repo_root = Some(root); + } + None => { + self.is_repo = false; + self.repo_root = None; + } + } + } + + /// Adopt the parent repository awaiting confirmation, using it for all git + /// operations on the current workspace. + pub(super) fn adopt_pending_repo(&mut self) { + if let Some(root) = self.pending_repo.take() { + self.status = format!("Using git repository at {}", root.display()); + self.is_repo = true; + self.repo_root = Some(root); + } + } + + /// Decline the parent repository; the workspace stays without version + /// control (the user can still `Init git` to create a nested repo). + pub(super) fn decline_pending_repo(&mut self) { + self.pending_repo = None; + self.is_repo = false; + self.repo_root = None; + self.status = "Not using the enclosing git repository".to_string(); + } + + /// Read every file and return its current on-disk word count. + pub(super) fn snapshot_counts(&self) -> HashMap { + self.files + .iter() + .map(|name| { + let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); + (name.clone(), count_words(&text)) + }) + .collect() + } + + /// Read every file and return its cached header info (slug/POV/goal/prose), + /// for files that carry anything worth showing in the list. + pub(super) fn snapshot_file_meta(&self) -> HashMap { + self.files + .iter() + .filter_map(|name| { + let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); + let meta = FileMeta::from_markdown(&text, &self.config.draft_marker); + meta.has_display().then(|| (name.clone(), meta)) + }) + .collect() + } + + pub(super) fn persist_order(&self) { + let _ = order::write_order(self.workspace(), &self.files); + } + + pub(super) fn persist_titles(&self) { + let _ = order::write_titles(self.workspace(), &self.titles); + } + + /// Store the title override for the selected file from `title_input`. + /// An empty value removes the override (falls back to the auto title). + pub(super) fn set_title_for_current(&mut self) { + if let Some(idx) = self.selected { + if let Some(name) = self.files.get(idx).cloned() { + let trimmed = self.title_input.trim(); + if trimmed.is_empty() { + self.titles.remove(&name); + } else { + self.titles.insert(name, trimmed.to_string()); + } + self.persist_titles(); + } + } + } + + pub(super) fn path_for(&self, name: &str) -> PathBuf { + self.workspace().join(name) + } + + /// Save the in-memory buffer to disk if it has unsaved changes. + pub(super) fn save_current(&mut self) { + if let Some(idx) = self.selected { + if self.dirty { + if let Some(name) = self.files.get(idx).cloned() { + let path = self.path_for(&name); + match std::fs::write(&path, &self.buffer) { + Ok(_) => { + self.dirty = false; + self.status = format!("Saved {name}"); + // Keep the file-list cache (slug/POV/goal/prose) in step. + let meta = + FileMeta::from_markdown(&self.buffer, &self.config.draft_marker); + if meta.has_display() { + self.file_meta.insert(name, meta); + } else { + self.file_meta.remove(&name); + } + self.merge_field_names_from_buffer(); + } + Err(e) => self.status = format!("Save failed: {e}"), + } + } + } + } + } + + pub(super) fn select(&mut self, idx: usize) { + if self.selected == Some(idx) { + return; + } + self.save_current(); + self.clear_lt(); + // Matches from the previous file's buffer are meaningless now. + self.find_matches.clear(); + self.find_active = 0; + self.find_needs_refresh = true; + // Re-run the live spell check on the newly loaded buffer immediately. + self.spell_matches.clear(); + self.spell_checked_text.clear(); + self.spell_dirty = true; + self.spell_last_edit = None; + self.spell_menu = None; + if let Some(name) = self.files.get(idx) { + let path = self.path_for(name); + self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); + self.selected = Some(idx); + self.dirty = false; + self.pending_delete = false; + self.rename_input = Path::new(name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + self.title_input = self.titles.get(name).cloned().unwrap_or_default(); + } + } + + pub(super) fn create_file(&mut self) { + let mut stem = self.new_name.trim().to_string(); + if stem.is_empty() { + self.status = "Enter a name for the new file".to_string(); + return; + } + if stem.to_lowercase().ends_with(".md") { + stem.truncate(stem.len() - 3); + } + let name = format!("{stem}.md"); + let path = self.path_for(&name); + if path.exists() { + self.status = format!("{name} already exists"); + return; + } + let seed = format!("# {stem}\n\n"); + match std::fs::write(&path, seed) { + Ok(_) => { + self.files.push(name.clone()); + self.persist_order(); + let idx = self.files.len() - 1; + self.selected = None; // force reload of buffer + self.select(idx); + self.new_name.clear(); + self.status = format!("Created {name}"); + } + Err(e) => self.status = format!("Create failed: {e}"), + } + } + + pub(super) fn delete_selected(&mut self) { + if let Some(idx) = self.selected { + if let Some(name) = self.files.get(idx).cloned() { + let path = self.path_for(&name); + match std::fs::remove_file(&path) { + Ok(_) => { + self.files.remove(idx); + self.titles.remove(&name); + self.session_start_counts.remove(&name); + self.file_meta.remove(&name); + self.persist_titles(); + self.persist_order(); + self.selected = None; + self.buffer.clear(); + self.dirty = false; + if !self.files.is_empty() { + self.select(idx.min(self.files.len() - 1)); + } + self.status = format!("Deleted {name}"); + } + Err(e) => self.status = format!("Delete failed: {e}"), + } + } + } + self.pending_delete = false; + } + + pub(super) fn rename_selected(&mut self) { + let Some(idx) = self.selected else { return }; + let mut stem = self.rename_input.trim().to_string(); + if stem.to_lowercase().ends_with(".md") { + stem.truncate(stem.len() - 3); + } + if stem.is_empty() { + self.status = "Enter a new name".to_string(); + return; + } + let new_name = format!("{stem}.md"); + let Some(old_name) = self.files.get(idx).cloned() else { + return; + }; + if new_name == old_name { + return; + } + let new_path = self.path_for(&new_name); + if new_path.exists() { + self.status = format!("{new_name} already exists"); + return; + } + // Persist any pending edits under the old name first. + self.save_current(); + match std::fs::rename(self.path_for(&old_name), &new_path) { + Ok(_) => { + self.files[idx] = new_name.clone(); + if let Some(title) = self.titles.remove(&old_name) { + self.titles.insert(new_name.clone(), title); + self.persist_titles(); + } + if let Some(words) = self.session_start_counts.remove(&old_name) { + self.session_start_counts.insert(new_name.clone(), words); + } + if let Some(meta) = self.file_meta.remove(&old_name) { + self.file_meta.insert(new_name.clone(), meta); + } + self.persist_order(); + self.status = format!("Renamed to {new_name}"); + } + Err(e) => self.status = format!("Rename failed: {e}"), + } + } + + pub(super) fn git_init(&mut self) { + // Initialising creates a repository in the workspace itself, which + // supersedes any enclosing repo we were about to ask about. + self.pending_repo = None; + let ws = self.workspace().to_path_buf(); + let outcome = gitsync::init(&ws); + self.git_log = outcome.log; + self.show_log = true; + self.is_repo = gitsync::is_repo(&ws); + self.repo_root = self.is_repo.then_some(ws); + self.status = if self.is_repo { + "Initialised git repository".to_string() + } else { + "git init failed (see log)".to_string() + }; + } + + pub(super) fn git_sync(&mut self) { + self.save_current(); + self.persist_order(); + self.persist_titles(); + let msg = format!( + "Sync manuscript {}", + chrono_like_timestamp() + ); + let outcome = gitsync::sync(self.workspace(), &msg); + self.git_log = outcome.log; + self.show_log = true; + self.status = if outcome.ok { + "Sync complete".to_string() + } else { + "Sync finished with errors (see log)".to_string() + }; + } + + /// Digits to zero-pad a defaulted chapter number to: the width of the largest + /// chapter number when padding is enabled, otherwise 1 (no padding). + pub(super) fn index_pad_width(&self) -> usize { + if self.config.zero_pad_index { + self.files.len().to_string().len().max(1) + } else { + 1 + } + } + + /// The title a chapter at `idx` would get without a manual override: its + /// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint + /// in the chapter-title field. + pub(super) fn auto_title(&self, idx: usize, markdown: &str) -> String { + let header = crate::preprocess::parse(markdown, &self.config.draft_marker); + resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width()) + } + + /// The current file's word-count target (from its `Word Count Target:` + /// header) paired with its current prose word count, if a target is set. + /// Prose = the body below the draft marker, so header metadata isn't counted. + pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { + self.selected?; + let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker); + let goal = header.goal?; + Some((goal, count_words(&header.body))) + } + + pub(super) fn export_odt(&mut self) { + self.save_current(); + let marker = self.config.draft_marker.clone(); + let pad_width = self.index_pad_width(); + let mut chapters = Vec::new(); + for (i, name) in self.files.iter().enumerate() { + let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); + // Strip comments + the editorial header, and lift out any + // `# Title:` / `# Slug:` metadata. + let header = crate::preprocess::parse(&raw, &marker); + + // Title priority: manual override > `# Title:` metadata > the chapter's + // 1-based position (e.g. "3."). The body from `parse` is used as-is. + let title = resolve_chapter_title( + self.titles.get(name).map(String::as_str), + header.title.as_deref(), + i, + pad_width, + ); + chapters.push(Chapter { + title, + slug: header.slug.clone(), + markdown: header.body, + }); + } + let out = PathBuf::from(self.export_input.trim()); + if let Some(parent) = out.parent() { + let _ = std::fs::create_dir_all(parent); + } + match odt::export(&chapters, &out) { + Ok(_) => { + self.config.export_path = out.clone(); + self.config.save(); + self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display()); + } + Err(e) => self.status = format!("Export failed: {e}"), + } + } + + /// Open a native folder picker to choose the workspace directory. + pub(super) fn browse_workspace(&mut self) { + let start = PathBuf::from(self.workspace_input.trim()); + let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder"); + if start.is_dir() { + dialog = dialog.set_directory(&start); + } + if let Some(path) = dialog.pick_folder() { + self.save_current(); + self.workspace_input = path.display().to_string(); + self.config.workspace = path; + self.config.save(); + self.open_workspace(); + } + } + + /// Open a native save dialog to choose the export `.odt` path. + pub(super) fn browse_export(&mut self) { + let current = PathBuf::from(self.export_input.trim()); + let mut dialog = rfd::FileDialog::new() + .set_title("Choose export file") + .add_filter("OpenDocument Text", &["odt"]); + if let Some(parent) = current.parent().filter(|p| p.is_dir()) { + dialog = dialog.set_directory(parent); + } + let name = current + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("manuscript.odt"); + if let Some(mut path) = dialog.set_file_name(name).save_file() { + // Ensure the chosen path ends in .odt even if the user omitted it. + let has_odt = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("odt")); + if !has_odt { + path.set_extension("odt"); + } + self.export_input = path.display().to_string(); + self.config.export_path = path; + self.config.save(); + } + } + + pub(super) fn reorder(&mut self, from: usize, mut to: usize) { + if from >= self.files.len() || from == to { + return; + } + // Remember the selected file by name so selection follows the move. + let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); + + let item = self.files.remove(from); + if from < to { + to -= 1; + } + to = to.min(self.files.len()); + self.files.insert(to, item); + self.persist_order(); + + if let Some(name) = selected_name { + self.selected = self.files.iter().position(|n| *n == name); + } + self.status = "Reordered".to_string(); + } +}