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, } /// 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, /// 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, /// 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, } 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(), 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, 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, }; 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(); 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() } 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) { let path = self.path_for(name); match std::fs::write(&path, &self.buffer) { Ok(_) => { self.dirty = false; self.status = format!("Saved {name}"); } 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.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); } 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()) } 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}"), }; } } /// 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)); 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", ); } }); }); 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); let row = ui .horizontal(|ui| { ui.dnd_drag_source( egui::Id::new(("dnd", &name)), idx, |ui| { ui.label( egui::RichText::new("⠿").monospace().weak(), ); }, ); if ui .add_sized( [ui.available_width(), 20.0], egui::SelectableLabel::new(selected, &name), ) .clicked() { clicked = Some(idx); } }) .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("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; } }); 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."); }); } } }); } 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)) }; let output = egui::TextEdit::multiline(&mut self.buffer) .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; } }); } /// 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.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_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() } /// 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"); } }