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(); + } +}